fix(sentry): honor every offered watcher lookback period (#3828)

Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
Clément Fiere
2026-09-20 01:04:28 +02:00
committed by GitHub
parent 2f44cd7b38
commit 1727435c14
13 changed files with 798 additions and 27 deletions
@@ -268,6 +268,20 @@ func TestHTTP_SearchIssues_RejectsMultipleProjectSlugs(t *testing.T) {
}
}
func TestHTTP_SearchIssues_RejectsInvalidStatsPeriod(t *testing.T) {
ctrl, router, client := newTestController(t)
inst := seedInstance(t, ctrl, "ws-1", "A", "tok")
client.searchIssuesFn = func(_ SearchFilter, _ string) (*SearchResult, error) {
t.Fatal("client must not be called for an invalid lookback")
return nil, nil
}
target := "/api/v1/sentry/issues?workspace_id=ws-1&instanceId=" + inst.ID +
"&orgSlug=acme&projectSlug=frontend&statsPeriod=nonsense"
if w := do(router, http.MethodGet, target, ""); w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, body=%s", w.Code, w.Body.String())
}
}
func TestHTTP_GetIssue_ForwardsID(t *testing.T) {
ctrl, router, client := newTestController(t)
inst := seedInstance(t, ctrl, "ws-1", "A", "tok")
+36 -21
View File
@@ -393,6 +393,7 @@ func (c *RESTClient) searchIssues(
return nil, &APIError{StatusCode: http.StatusBadRequest, Message: "searchIssues: at most one project slug per request"}
}
q := url.Values{}
projectSlug := projectSlugForRequest(filter)
if limit > 0 {
q.Set("per_page", strconv.Itoa(limit))
}
@@ -403,7 +404,13 @@ func (c *RESTClient) searchIssues(
if filter.Environment != "" {
q.Set("environment", filter.Environment)
}
if filter.StatsPeriod != "" {
// statsPeriod is endpoint-specific: the project-scoped endpoint accepts
// only ''/24h/14d there, while the organization-scoped endpoint accepts any
// relative duration and uses it as its request time range. Send it only to
// the latter, and never empty — the org-scoped endpoint rejects an empty
// value. The `age:` term built in buildIssueQueryString is the age
// constraint both endpoints honor.
if filter.StatsPeriod != "" && projectSlug == "" {
q.Set("statsPeriod", filter.StatsPeriod)
}
if cursor != "" {
@@ -413,7 +420,7 @@ func (c *RESTClient) searchIssues(
q.Set("query", built)
}
var nodes []issueNode
resp, err := c.do(ctx, issuesSearchPath(filter.OrgSlug, projectSlugForRequest(filter)), q, &nodes)
resp, err := c.do(ctx, issuesSearchPath(filter.OrgSlug, projectSlug), q, &nodes)
if err != nil {
return nil, err
}
@@ -463,9 +470,9 @@ func projectSlugForRequest(f SearchFilter) string {
// watch filters are limited to a single status (enforced by
// validateFilterStatuses) because two `is:` tokens would AND-combine and match
// nothing. StatsPeriod is translated into an `age:` token — see
// statsPeriodAgeToken's doc comment for why that translation, not the
// `statsPeriod` query param set separately in searchIssues, is what actually
// restricts which issues come back.
// statsPeriodAgeToken's doc comment for why that term, rather than the
// endpoint-specific `statsPeriod` query param set separately in searchIssues,
// is the age constraint both issue endpoints honor.
//
// The free-text Query is parenthesized whenever it is combined with another
// token below. Sentry's search grammar ANDs implicitly-adjacent terms tighter
@@ -515,9 +522,10 @@ func buildIssueQueryString(f SearchFilter) string {
return strings.Join(parts, " ")
}
// statsPeriodPattern matches Sentry's relative-duration syntax: an integer
// followed by h(ours)/d(ays)/w(eeks) — the same units Sentry's `age:` search
// token and this integration's StatsPeriod values (1h, 24h, 7d, 14d, 30d) use.
// statsPeriodPattern matches the relative-duration syntax this integration
// accepts: an integer followed by h(ours)/d(ays)/w(eeks). Deliberately narrower
// than Sentry's own relative-duration grammar, which also accepts seconds and
// minutes — see parseStatsPeriodUnits.
var statsPeriodPattern = regexp.MustCompile(`^[1-9]\d*[hdw]$`)
// maxStatsPeriodUnits bounds the numeric component parseStatsPeriodUnits
@@ -531,12 +539,18 @@ var statsPeriodPattern = regexp.MustCompile(`^[1-9]\d*[hdw]$`)
// nonsensical in one path can't still pass as a valid filter in the other.
const maxStatsPeriodUnits = 3650
// parseStatsPeriodUnits validates period against Sentry's relative-duration
// syntax and the shared accepted range, returning the numeric component and
// unit byte ('h'/'d'/'w') on success. statsPeriodAgeToken below and
// mock_client.go's parseStatsPeriod both build on this so the real REST
// client and the mock that backs E2E tests accept or reject the exact same
// input.
// parseStatsPeriodUnits validates period against the shared accepted syntax
// and range, returning the numeric component and unit byte ('h'/'d'/'w') on
// success. It is the single accepted-value authority: the write sites reject a
// stored lookback it cannot express, statsPeriodAgeToken below derives the
// query term from it, and mock_client.go's parseStatsPeriod builds on it, so
// the real REST client and the mock that backs E2E tests accept or reject the
// exact same input.
//
// The syntax is narrower than Sentry's own relative-duration grammar, which
// also accepts seconds and minutes, so a "30m" lookback is rejected here even
// though an `age:-30m` term would be valid upstream: every Kandev surface
// offers only whole hours, days, or weeks.
func parseStatsPeriodUnits(period string) (n int, unit byte, ok bool) {
period = strings.TrimSpace(period)
if !statsPeriodPattern.MatchString(period) {
@@ -555,13 +569,14 @@ func parseStatsPeriodUnits(period string) (n int, unit byte, ok bool) {
// input, leaving the query unfiltered by age exactly as before this
// translation existed.
//
// This exists because Sentry's `statsPeriod` query param (set separately in
// searchIssues) does NOT filter which issues an issue search returns — it
// only sizes the per-issue event-count stats window returned alongside each
// result (https://github.com/getsentry/sentry/issues/36375). Both the issue
// browser and issue watches expose StatsPeriod as "how far back to look for
// matching issues"; without this translation a watch configured for e.g. the
// last 24h silently matches (and creates tasks for) issues of any age.
// Both the issue browser and issue watches expose StatsPeriod as "how far back
// to look for matching issues", and this token is the constraint that applies
// on both issue endpoints. The `statsPeriod` query param (set separately in
// searchIssues) cannot carry that meaning on its own: the project-scoped
// endpoint accepts only 24h and 14d there and rejects every other offered
// value with a 400, and only the organization-scoped endpoint turns it into a
// request time range. Without this translation a watch configured for the last
// 24h would silently match (and create tasks for) issues of any age.
func statsPeriodAgeToken(period string) string {
period = strings.TrimSpace(period)
if _, _, ok := parseStatsPeriodUnits(period); !ok {
@@ -4,6 +4,7 @@ import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
@@ -209,6 +210,86 @@ func TestRESTClient_SearchIssues_AllProjectsUsesOrgEndpoint(t *testing.T) {
}
}
// TestRESTClient_SearchIssues_ForwardsLookbackOnlyWhereAccepted locks in the
// fix for the "only 24h and 14d work" report. The two issue endpoints accept
// different statsPeriod sets: the project-scoped endpoint accepts only the
// empty value, 24h, and 14d there and answers 400 for every other offered
// lookback, while the organization-scoped endpoint takes any relative duration
// and uses it as its request time range. The lookback must therefore reach only
// the org-scoped request, while the `age:` term that actually limits issue
// eligibility is sent on both.
//
// @covers AC-INTEGRATIONS-SENTRY-WATCHER-LOOKBACK-PERIODS-001.1, AC-INTEGRATIONS-SENTRY-WATCHER-LOOKBACK-PERIODS-001.2, AC-INTEGRATIONS-SENTRY-WATCHER-LOOKBACK-PERIODS-001.5
func TestRESTClient_SearchIssues_ForwardsLookbackOnlyWhereAccepted(t *testing.T) {
type capture struct {
path string
query url.Values
}
requests := make([]capture, 0, 4)
ts := newMockServer(t, func(w http.ResponseWriter, r *http.Request) {
requests = append(requests, capture{path: r.URL.Path, query: r.URL.Query()})
_, _ = w.Write([]byte(`[]`))
})
c := pointTo(NewRESTClient(&SentryConfig{}, "tok"), ts.URL)
search := func(filter SearchFilter) capture {
t.Helper()
before := len(requests)
if _, err := c.SearchIssues(context.Background(), filter, ""); err != nil {
t.Fatalf("search %+v: %v", filter, err)
}
if len(requests) != before+1 {
t.Fatalf("expected one request per search, got %d", len(requests)-before)
}
return requests[before]
}
// Every offered lookback token must be expressible on both paths.
for _, token := range []string{"1h", "24h", "7d", "14d", "30d"} {
projectScoped := search(SearchFilter{
OrgSlug: "acme",
ProjectSlugs: []string{"frontend"},
StatsPeriod: token,
})
if projectScoped.path != "/projects/acme/frontend/issues/" {
t.Errorf("%s: project-scoped path = %q", token, projectScoped.path)
}
if projectScoped.query.Has("statsPeriod") {
t.Errorf("%s: project-scoped request must not carry statsPeriod, got %q",
token, projectScoped.query.Get("statsPeriod"))
}
if got := projectScoped.query.Get("query"); !strings.Contains(got, "age:-"+token) {
t.Errorf("%s: project-scoped query %q missing the age:-%s term", token, got, token)
}
orgScoped := search(SearchFilter{OrgSlug: "acme", StatsPeriod: token})
if orgScoped.path != "/organizations/acme/issues/" {
t.Errorf("%s: org-scoped path = %q", token, orgScoped.path)
}
if got := orgScoped.query.Get("statsPeriod"); got != token {
t.Errorf("%s: org-scoped statsPeriod = %q, want %q", token, got, token)
}
if got := orgScoped.query.Get("query"); !strings.Contains(got, "age:-"+token) {
t.Errorf("%s: org-scoped query %q missing the age:-%s term", token, got, token)
}
}
// An empty lookback carries neither the parameter nor an age term, on
// either path. The parameter key must be absent, not merely empty: the
// org-scoped endpoint rejects an empty statsPeriod.
for _, filter := range []SearchFilter{
{OrgSlug: "acme", ProjectSlugs: []string{"frontend"}},
{OrgSlug: "acme"},
} {
got := search(filter)
if got.query.Has("statsPeriod") {
t.Errorf("empty lookback must not send statsPeriod, got %q", got.query.Get("statsPeriod"))
}
if q := got.query.Get("query"); strings.Contains(q, "age:") {
t.Errorf("empty lookback must not send an age term, got %q", q)
}
}
}
// TestRESTClient_GetIssue_NumericID locks in that a numeric internal id hits
// the /issues/{id}/ endpoint directly (no org needed).
func TestRESTClient_GetIssue_NumericID(t *testing.T) {
@@ -311,11 +392,10 @@ func TestBuildIssueQueryString(t *testing.T) {
// TestBuildIssueQueryString_StatsPeriodBecomesAgeFilter locks in the fix for
// the "older issues get processed by a watch configured for the last 24h"
// bug: Sentry's statsPeriod query param does NOT filter which issues a
// search returns (it only sizes the per-issue stats window — see
// https://github.com/getsentry/sentry/issues/36375). The only way to
// actually restrict results to recently-first-seen issues is the `age:`
// search token, so StatsPeriod must be translated into one.
// bug: the `statsPeriod` request param cannot express that intent on the
// project-scoped issue endpoint, which accepts only 24h and 14d there. The
// `age:` search token is the age constraint both endpoints honor, so
// StatsPeriod must be translated into one.
func TestBuildIssueQueryString_StatsPeriodBecomesAgeFilter(t *testing.T) {
if got := buildIssueQueryString(SearchFilter{StatsPeriod: "24h"}); got != "age:-24h" {
t.Errorf("expected age:-24h, got %q", got)
+7 -1
View File
@@ -501,8 +501,14 @@ func (s *Service) ListProjects(ctx context.Context, workspaceID, instanceID stri
return client.ListProjects(ctx)
}
// SearchIssues runs a filtered search against one instance's client.
// SearchIssues runs a filtered search against one instance's client. The
// lookback is validated here because browse requests do not pass through the
// issue-watch write validation path.
func (s *Service) SearchIssues(ctx context.Context, workspaceID, instanceID string, filter SearchFilter, cursor string) (*SearchResult, error) {
filter.StatsPeriod = strings.TrimSpace(filter.StatsPeriod)
if err := validateFilterStatsPeriod(filter); err != nil {
return nil, err
}
client, err := s.browseClient(ctx, workspaceID, instanceID)
if err != nil {
return nil, err
@@ -143,6 +143,9 @@ func (s *Service) UpdateIssueWatch(ctx context.Context, id string, req *UpdateIs
if err := validateFilterStatuses(w.Filter); err != nil {
return nil, err
}
if err := validateFilterStatsPeriod(w.Filter); err != nil {
return nil, err
}
}
if w.WorkflowID == "" || w.WorkflowStepID == "" {
return nil, fmt.Errorf("%w: workflowId and workflowStepId cannot be empty", ErrInvalidConfig)
@@ -238,6 +241,19 @@ func (s *Service) CheckIssueWatch(ctx context.Context, w *IssueWatch) (string, [
s.stampWatchError(w.ID, err.Error())
return "", nil, err
}
if w.Filter.StatsPeriod != "" {
if _, _, ok := parseStatsPeriodUnits(w.Filter.StatsPeriod); !ok {
// A stored lookback outside the supported syntax has no `age:` term
// to constrain the search, so fail the poll closed instead of
// matching (and creating tasks for) issues of any age. Watch rows
// created before the write guard reject such a value, and they are
// never rewritten here.
err := fmt.Errorf("%w: watch lookback period %q is not a whole number of hours, days, or weeks",
ErrInvalidConfig, w.Filter.StatsPeriod)
s.stampWatchError(w.ID, err.Error())
return "", nil, err
}
}
instanceID, err := s.resolveWatchInstanceID(ctx, w)
if err != nil {
s.stampWatchError(w.ID, err.Error())
@@ -464,6 +480,9 @@ func validateIssueWatchCreate(req *CreateIssueWatchRequest) error {
if err := validateFilterStatuses(nf); err != nil {
return err
}
if err := validateFilterStatsPeriod(nf); err != nil {
return err
}
if err := validateMaxInflightTasks(req.MaxInflightTasks); err != nil {
return err
}
@@ -513,6 +532,21 @@ func validateFilterStatuses(f SearchFilter) error {
return nil
}
// validateFilterStatsPeriod rejects a non-empty lookback value that the shared
// accepted syntax (see parseStatsPeriodUnits) cannot express. The field is
// optional, so an empty value stays valid; like validateFilterStatuses this is
// applied only when a filter is created or changed, never against an unchanged
// stored one, so a legacy row cannot block an unrelated update.
func validateFilterStatsPeriod(f SearchFilter) error {
if f.StatsPeriod == "" {
return nil
}
if _, _, ok := parseStatsPeriodUnits(f.StatsPeriod); !ok {
return fmt.Errorf("%w: filter.statsPeriod must be a whole number of hours, days, or weeks such as 24h or 7d", ErrInvalidConfig)
}
return nil
}
func validatePollInterval(seconds int) error {
if seconds < MinIssueWatchPollInterval || seconds > MaxIssueWatchPollInterval {
return fmt.Errorf("%w: pollIntervalSeconds must be between %d and %d",
@@ -102,6 +102,121 @@ func TestService_UpdateIssueWatch_LegacyMultiStatusToggle(t *testing.T) {
}
}
// TestService_UpdateIssueWatch_PersistsLookbackPeriod covers the update half of
// the offered-token contract: a filter-carrying update stores the selected
// lookback unchanged, so a non-default choice survives the write path.
//
// @covers AC-INTEGRATIONS-SENTRY-WATCHER-LOOKBACK-PERIODS-001.1
func TestService_UpdateIssueWatch_PersistsLookbackPeriod(t *testing.T) {
f := newSvcFixture(t)
ctx := context.Background()
created, err := f.svc.CreateIssueWatch(ctx, &CreateIssueWatchRequest{
WorkspaceID: "ws-1", SentryInstanceID: f.ensureInstance(t, "ws-1"),
WorkflowID: "wf", WorkflowStepID: "step", Filter: validFilter(),
})
if err != nil {
t.Fatalf("create: %v", err)
}
filter := validFilter()
filter.StatsPeriod = "30d"
if _, err := f.svc.UpdateIssueWatch(ctx, created.ID, &UpdateIssueWatchRequest{Filter: &filter}); err != nil {
t.Fatalf("update: %v", err)
}
reloaded, err := f.store.GetIssueWatch(ctx, created.ID)
if err != nil {
t.Fatalf("reload: %v", err)
}
if reloaded.Filter.StatsPeriod != "30d" {
t.Errorf("stored lookback = %q, want %q", reloaded.Filter.StatsPeriod, "30d")
}
}
// TestService_IssueWatch_LookbackPeriodValidation covers the two guards that
// replace the project-scoped request parameter as the rejection point for a
// lookback outside Kandev's supported syntax (a whole number of hours, days,
// or weeks): the write sites reject it, an empty value stays acceptable, an
// unrelated partial update never re-validates a stored value, and a row that
// already stores one fails its poll closed without searching.
//
// @covers AC-INTEGRATIONS-SENTRY-WATCHER-LOOKBACK-PERIODS-001.4, AC-INTEGRATIONS-SENTRY-WATCHER-LOOKBACK-PERIODS-001.6
func TestService_IssueWatch_LookbackPeriodValidation(t *testing.T) {
f := newSvcFixture(t)
ctx := context.Background()
instID := f.ensureInstance(t, "ws-1")
createReq := func(filter SearchFilter) *CreateIssueWatchRequest {
return &CreateIssueWatchRequest{
WorkspaceID: "ws-1", SentryInstanceID: instID,
WorkflowID: "wf", WorkflowStepID: "step", Filter: filter,
}
}
// 30m is a valid Sentry relative duration but outside the accepted set. Both
// write sites must reject it: the shared parser derives nothing from it, so
// the stored search would carry no age term at all and the watch would match
// issues of any age.
unsupported := validFilter()
unsupported.StatsPeriod = "30m"
if _, err := f.svc.CreateIssueWatch(ctx, createReq(unsupported)); !errors.Is(err, ErrInvalidConfig) {
t.Errorf("create with a 30m lookback must be rejected, got %v", err)
}
created, err := f.svc.CreateIssueWatch(ctx, createReq(validFilter()))
if err != nil {
t.Fatalf("create: %v", err)
}
if _, err := f.svc.UpdateIssueWatch(ctx, created.ID, &UpdateIssueWatchRequest{
Filter: &unsupported,
}); !errors.Is(err, ErrInvalidConfig) {
t.Errorf("filter-carrying update with a 30m lookback must be rejected, got %v", err)
}
// An empty lookback stays accepted: the field is optional and a legacy row
// may store it.
for _, empty := range []string{"", " "} {
filter := validFilter()
filter.StatsPeriod = empty
if _, err := f.svc.CreateIssueWatch(ctx, createReq(filter)); err != nil {
t.Errorf("empty lookback %q must be accepted, got %v", empty, err)
}
}
// A row that already stores an unsupported value is neither rewritten nor
// re-validated by an update that omits the filter.
stored := newTestIssueWatch("ws-1")
stored.Filter.StatsPeriod = "30m"
if err := f.store.CreateIssueWatch(ctx, stored); err != nil {
t.Fatalf("seed watch: %v", err)
}
disabled := false
if _, err := f.svc.UpdateIssueWatch(ctx, stored.ID, &UpdateIssueWatchRequest{
Enabled: &disabled,
}); err != nil {
t.Errorf("unrelated partial update must not re-validate the stored lookback, got %v", err)
}
// That row's poll fails closed: the error is recorded and no search is
// issued, so an old issue cannot become a task through an age-less query.
searched := false
f.client.searchIssuesFn = func(_ SearchFilter, _ string) (*SearchResult, error) {
searched = true
return &SearchResult{IsLast: true}, nil
}
if _, _, err := f.svc.CheckIssueWatch(ctx, stored); !errors.Is(err, ErrInvalidConfig) {
t.Errorf("poll with a stored 30m lookback must fail closed, got %v", err)
}
if searched {
t.Error("poll must not search when the stored lookback is outside the supported syntax")
}
refreshed, err := f.store.GetIssueWatch(ctx, stored.ID)
if err != nil {
t.Fatalf("reload watch: %v", err)
}
if refreshed.LastError == "" {
t.Error("expected the poll failure recorded on the watch")
}
}
func TestService_UpdateIssueWatch_PartialPatch(t *testing.T) {
f := newSvcFixture(t)
ctx := context.Background()
@@ -339,6 +454,42 @@ func TestService_CheckIssueWatch_ClearsLastErrorAfterSuccess(t *testing.T) {
}
}
// TestService_CheckIssueWatch_PollsStoredLookbackPeriod proves an existing
// watch keeps polling with the lookback token it stored: the row round-trips
// through the store unaltered and the search client receives that token.
//
// @covers AC-INTEGRATIONS-SENTRY-WATCHER-LOOKBACK-PERIODS-001.3
func TestService_CheckIssueWatch_PollsStoredLookbackPeriod(t *testing.T) {
f := newSvcFixture(t)
ctx := context.Background()
f.seedInstance(t, "ws-1", "Primary", "tok")
w := newTestIssueWatch("ws-1")
w.Filter.StatsPeriod = "30d"
if err := f.store.CreateIssueWatch(ctx, w); err != nil {
t.Fatalf("seed watch: %v", err)
}
var polled []string
f.client.searchIssuesFn = func(filter SearchFilter, _ string) (*SearchResult, error) {
polled = append(polled, filter.StatsPeriod)
return &SearchResult{IsLast: true}, nil
}
reloaded, err := f.store.GetIssueWatch(ctx, w.ID)
if err != nil {
t.Fatalf("reload watch: %v", err)
}
if reloaded.Filter.StatsPeriod != "30d" {
t.Fatalf("stored lookback = %q, want %q", reloaded.Filter.StatsPeriod, "30d")
}
if _, _, err := f.svc.CheckIssueWatch(ctx, reloaded); err != nil {
t.Fatalf("check: %v", err)
}
if len(polled) != 1 || polled[0] != "30d" {
t.Errorf("search client received lookbacks %v, want [30d]", polled)
}
}
// TestService_CheckIssueWatch_ResolvesSoleInstance pins acceptance (b): an
// unbound (NULL-instance) watch resolves to its workspace's sole instance at
// poll time.
@@ -650,6 +650,32 @@ func TestService_Browse_RequireInstanceAndForwardsFilter(t *testing.T) {
}
}
func TestService_Browse_RejectsInvalidStatsPeriod(t *testing.T) {
f := newSvcFixture(t)
ctx := context.Background()
inst := f.seedInstance(t, "ws-1", "A", "t")
called := false
f.client.searchIssuesFn = func(_ SearchFilter, _ string) (*SearchResult, error) {
called = true
return &SearchResult{IsLast: true}, nil
}
for name, filter := range map[string]SearchFilter{
"project scoped": {OrgSlug: "acme", ProjectSlugs: []string{"frontend"}, StatsPeriod: "nonsense"},
"organization scoped": {OrgSlug: "acme", StatsPeriod: "nonsense"},
} {
t.Run(name, func(t *testing.T) {
called = false
if _, err := f.svc.SearchIssues(ctx, "ws-1", inst.ID, filter, ""); !errors.Is(err, ErrInvalidConfig) {
t.Fatalf("expected ErrInvalidConfig, got %v", err)
}
if called {
t.Fatal("invalid lookback must be rejected before the client search")
}
})
}
}
func TestService_GetIssue_NotConfiguredWhenNoSecret(t *testing.T) {
f := newSvcFixture(t)
ctx := context.Background()
@@ -1,5 +1,6 @@
import { describe, it, expect } from "vitest";
import {
STATS_PERIOD_OPTIONS,
orgSelectItems,
projectMultiSelectItems,
maxInflightTasksString,
@@ -17,6 +18,24 @@ const proj = (slug: string, name: string, orgSlug = "acme"): SentryProject => ({
orgSlug,
});
describe("STATS_PERIOD_OPTIONS", () => {
// The values are wire data: they are persisted on the watch filter and sent
// to Sentry, and the backend write guard rejects anything outside whole
// hours, days, or weeks. Pinning the exact set keeps a token from drifting
// away from its label or from the set the backend accepts.
//
// @covers AC-INTEGRATIONS-SENTRY-WATCHER-LOOKBACK-PERIODS-001.1
it("offers exactly the lookback tokens the backend accepts", () => {
expect(STATS_PERIOD_OPTIONS.map((option) => option.value)).toEqual([
"1h",
"24h",
"7d",
"14d",
"30d",
]);
});
});
describe("orgSelectItems", () => {
it("lists the orgs the token can see", () => {
const items = orgSelectItems(["acme", "globex"], "");
@@ -197,4 +197,65 @@ test.describe("Sentry settings — issue watchers", () => {
caption: "Saved watcher polling both selected projects",
});
});
// One non-default lookback token carried end to end through the dialog, so
// the assertion cannot pass on the dialog's 24h default. The full token
// matrix is covered elsewhere: the request-shape test sends every offered
// token on both endpoints, and the form test pins the option values.
//
// @covers AC-INTEGRATIONS-SENTRY-WATCHER-LOOKBACK-PERIODS-001.1, AC-INTEGRATIONS-SENTRY-WATCHER-LOOKBACK-PERIODS-001.3
test("persists selected lookback period", async ({ testPage, apiClient, seedData }) => {
await apiClient.mockSentryReset();
const instance = await apiClient.createSentryInstance({
workspaceId: seedData.workspaceId,
name: "Lookback Sentry",
secret: TOKEN,
});
await apiClient.mockSentrySetAuthHealth({ instanceId: instance.id, ok: true });
await apiClient.mockSentrySetOrganizations(instance.id, [
{ id: "acme", slug: "acme", name: "Acme" },
]);
await apiClient.mockSentrySetProjects(instance.id, [
{ id: "frontend", slug: "frontend", name: "Frontend", orgSlug: "acme" },
]);
const settings = new SentrySettingsPage(testPage);
await settings.goto(seedData.workspaceId);
await testPage.getByRole("button", { name: "New watcher" }).click();
const dialog = testPage.getByRole("dialog");
await expect(dialog).toBeVisible();
const pick = async (label: string, option: string | RegExp) => {
await comboboxByLabel(dialog, label).click();
await testPage.getByRole("listbox").getByRole("option", { name: option }).click();
};
await pick("Sentry instance", "Lookback Sentry");
// The sole org auto-selects once the lookup resolves.
await expect(comboboxByLabel(dialog, "Organization slug")).toContainText("acme");
await comboboxByLabel(dialog, "Project slug").click();
await testPage
.getByRole("listbox")
.getByRole("option", { name: "Frontend (frontend)" })
.click();
await expect(comboboxByLabel(dialog, "Project slug")).toContainText("Frontend (frontend)");
await testPage.keyboard.press("Escape");
await pick("Stats period", "Last 30 days");
await pick("Workflow", "E2E Workflow");
await comboboxByLabel(dialog, "Workflow Step").click();
await testPage.getByRole("listbox").getByRole("option").first().click();
const createButton = dialog.getByRole("button", { name: "Create" });
await expect(createButton).toBeEnabled();
await createButton.click();
await expect(dialog).toBeHidden();
// Reload before asserting so the summary comes from the persisted row.
await settings.goto(seedData.workspaceId);
await expect(testPage.getByText("period:30d")).toBeVisible();
});
});
@@ -0,0 +1,108 @@
---
created: 2026-09-18
status: implemented
requirements:
- REQ-INTEGRATIONS-SENTRY-WATCHER-LOOKBACK-PERIODS-001
system_design:
- ../../specs/integrations/system-design/sentry-watcher-lookback-periods.md
legacy_specs: []
---
# Implementation Plan: Sentry Watcher Lookback Periods
## Overview
Send the selected Sentry lookback only to the issue endpoint whose accepted values include it. The project-scoped endpoint currently rejects every offered token except `24h` and `14d`, which is the reported watch failure; the organization-scoped endpoint accepts all of them and uses the value as its request time range, so it keeps receiving it. Order: prove the per-endpoint request shape, gate the parameter, guard the write and poll paths, correct the comments that assert the old mechanism, then confirm the user-visible save and reload path.
## Scope
### In scope
- Keep the existing `1h`, `24h`, `7d`, `14d`, and `30d` choices unchanged.
- Stop sending `statsPeriod` on the project-scoped issue request; keep sending it on the organization-scoped request when a lookback is selected.
- Preserve the existing `age:-<period>` eligibility constraint on both.
- Reject a non-empty lookback value outside Kandev's supported lookback syntax, on watch create and on a filter-carrying update; keep an empty value accepted.
- Fail a poll closed when a stored lookback value is outside that syntax, so such a row creates no tasks.
- Reject an invalid non-empty lookback on direct issue-browse requests before the provider client runs.
- Correct the request/comment claims that the parameter never affects which issues are returned.
- Add REST-client, service, and settings-E2E coverage.
### Out of scope
- Changing watch polling cadence, deduplication, or task dispatch.
## Technical approach
`RESTClient.searchIssues` sets `statsPeriod` unconditionally today behind its existing non-empty check, while `issuesSearchPath` already selects the endpoint from `projectSlugForRequest(filter)`. Gate the parameter on that same predicate while keeping the non-empty condition, so the value travels only to the endpoint that accepts it and an empty lookback sends nothing, and comment the pair as one decision.
Add `TestRESTClient_SearchIssues_ForwardsLookbackOnlyWhereAccepted` to `apps/backend/internal/sentry/rest_client_test.go` using the existing `newMockServer`/`pointTo` harness: for every offered token, assert a project-scoped request carries `age:-<token>` with no `statsPeriod`, and an org-scoped request carries both; also assert that an empty lookback carries neither, on either path. Run it red before the change, then green.
Add the fail-closed guard in `internal/sentry/service_issue_watch.go`: reject `StatsPeriod` only when it is non-empty and `parseStatsPeriodUnits` cannot express it, on create and on the `req.Filter != nil` branch of update, mirroring `validateFilterStatuses` so an unrelated partial update never re-validates a stored value. The deleted project-scoped parameter used to be the only rejection of an inexpressible token, so `CheckIssueWatch` also needs the matching poll-time guard: a row that already stores such a value must stamp the watch error and return without searching, mirroring its existing project-less guard, or the fix would let that row dispatch tasks for issues of any age.
Apply the same `validateFilterStatsPeriod` guard in `Service.SearchIssues` for the direct issue browser. Trim the value first, reject invalid non-empty values before resolving the instance client, and rely on the existing handler mapping from `ErrInvalidConfig` to HTTP 400. This keeps project- and organization-scoped browse requests from silently dropping an invalid age constraint.
Correct the comments that misstate the parameter's effect: three assert that it never affects which issues a search returns — false for the organization-scoped endpoint, where the value becomes that request's time range — and two present Kandev's hour/day/week set as Sentry's own relative-duration syntax, which also accepts seconds and minutes. The work order lists the exact sites.
Extend `apps/web/e2e/tests/integrations/sentry-settings.spec.ts` with a scenario titled `persists selected lookback period` in the existing issue-watcher block: select the `Last 30 days` option (`30d`), create the watch, reload, and assert the persisted summary.
## Tests
- `AC-INTEGRATIONS-SENTRY-WATCHER-LOOKBACK-PERIODS-001.1`, `.2`, and `.5`: `TestRESTClient_SearchIssues_ForwardsLookbackOnlyWhereAccepted` in `apps/backend/internal/sentry/rest_client_test.go` covers every offered token across both endpoint paths, and `TestService_UpdateIssueWatch_PersistsLookbackPeriod` in `apps/backend/internal/sentry/service_issue_watch_test.go` covers the update half of `AC-INTEGRATIONS-SENTRY-WATCHER-LOOKBACK-PERIODS-001.1`; that criterion's create half rides the E2E scenario below.
- `AC-INTEGRATIONS-SENTRY-WATCHER-LOOKBACK-PERIODS-001.3`: `TestService_CheckIssueWatch_PollsStoredLookbackPeriod` in the same file proves a stored non-default token survives a store round-trip and reaches the search client unchanged.
- `AC-INTEGRATIONS-SENTRY-WATCHER-LOOKBACK-PERIODS-001.4`: `TestService_IssueWatch_LookbackPeriodValidation` in the same file rejects a value outside the supported syntax (`30m`) on create and on a filter-carrying update, accepts an empty value, and lets an unrelated partial update through when the stored value is one the syntax rejects.
- `AC-INTEGRATIONS-SENTRY-WATCHER-LOOKBACK-PERIODS-001.1` (offered set): `STATS_PERIOD_OPTIONS` in `apps/web/components/sentry/sentry-issue-watch-form.test.ts` pins the five option values, so the dialog cannot offer a token the backend's write guard rejects.
- `AC-INTEGRATIONS-SENTRY-WATCHER-LOOKBACK-PERIODS-001.6`: the same test proves the poll refusal: a stored value outside the syntax stamps the watch error and returns without calling the search client.
- Direct browse validation: `TestService_Browse_RejectsInvalidStatsPeriod` rejects invalid periods for project- and organization-scoped searches before the client call, and `TestHTTP_SearchIssues_RejectsInvalidStatsPeriod` proves the HTTP boundary returns 400.
## E2E tests
- `AC-INTEGRATIONS-SENTRY-WATCHER-LOOKBACK-PERIODS-001.1` and `.3`: the `persists selected lookback period` scenario in `apps/web/e2e/tests/integrations/sentry-settings.spec.ts` (project `chromium`) selects `Last 30 days` (`30d`), creates the watch, reloads, and asserts the saved summary.
## Work orders
- [x] [Task 01: Honor all Sentry watcher lookback periods](task-01-honor-sentry-watcher-lookback-periods.md)
## Verification results
Ran from the repository root by the implementing sub-agent (commit `0c82dd006`), with the parent
re-running the focused tests and diagnosing the one failing gate:
- `(cd apps && pnpm install --frozen-lockfile)` — pass, workspace already current.
- `(cd apps/backend && go test -v -run '<the four tests>' ./internal/sentry)` — pass. The parent
re-ran it with `-count=1`: `ok github.com/kandev/kandev/internal/sentry 0.097s`. The whole
`./internal/sentry` package passes.
- `(cd apps/web && pnpm e2e:run tests/integrations/sentry-settings.spec.ts -- --grep "persists
selected lookback period")` — 1 passed (17.5s).
- `(cd apps/web && pnpm e2e:sleep-ratchet)` — clean.
- `make fmt` — clean.
- `make typecheck` — clean.
- `make test` — fails in four packages unrelated to this change: `internal/common/config`,
`internal/launcher`, `internal/system/updates`, and `internal/agentctl/server/config`. This
session exports `KANDEV_SERVER_PORT`, `KANDEV_BACKEND_PORT`, `KANDEV_TRUSTED_PROXIES`,
`KANDEV_RUNNING_AS_SERVICE`, and `KANDEV_VERSION`, which those tests read instead of their
fixtures. The parent reproduced one on the same tree: it fails under the ambient environment and
passes with those variables unset. No failing package is touched by this change.
- `make lint` — golangci-lint 0 issues, eslint clean, harness/specification/architecture lint pass.
- `python3 scripts/list-docs.py validate` — validated 291 decisions and 1036 specifications.
Test evidence: `TestRESTClient_SearchIssues_ForwardsLookbackOnlyWhereAccepted` and
`TestService_IssueWatch_LookbackPeriodValidation` were red before their production change; the two
persistence tests were green before and after, exactly as the design's test strategy states.
Delivery state lives here and in the work order, not in the paired specification: the design is
`current` because it describes the implemented system, while the requirement stays `draft` until the
change lands on `main`, since its `active` status describes the product contract rather than the
state of this branch.
## Risks
- The project-scoped request must lose the parameter without losing the `age:` term, or its searches would match issues of any age; the request test asserts the term rather than only asserting absence.
- The organization-scoped request keeps the parameter, so it stays exposed to that endpoint's accepted set; the request test pins the current contract.
- A previously stored inexpressible token is not rewritten and its polls fail closed, so the row stays idle until its lookback is replaced from the watch dialog: that dialog always sends the persisted filter, so saving another field alone is refused, and the period selector offers no way to clear the stored value.
- A watch whose polls previously failed for every stored token other than `24h` and `14d` resumes on the next poll and can import up to one page (100 issues) per configured project before deduplication catches up.
- The E2E mock enforces the window from the filter itself, so the E2E scenario cannot detect this regression on its own.
- Direct issue-browse requests now reject invalid non-empty periods at the service boundary, so an API caller cannot receive an unfiltered project search after the project endpoint stops receiving `statsPeriod`.
## Follow-up verification
- `(cd apps/backend && go test -run 'TestService_Browse_RejectsInvalidStatsPeriod|TestHTTP_SearchIssues_RejectsInvalidStatsPeriod' ./internal/sentry)` — pass.
@@ -0,0 +1,119 @@
---
id: "01-honor-sentry-watcher-lookback-periods"
title: "Honor all Sentry watcher lookback periods"
status: done
wave: 1
depends_on: []
plan: "plan.md"
requirements:
- REQ-INTEGRATIONS-SENTRY-WATCHER-LOOKBACK-PERIODS-001
acceptance_criteria:
- AC-INTEGRATIONS-SENTRY-WATCHER-LOOKBACK-PERIODS-001.1
- AC-INTEGRATIONS-SENTRY-WATCHER-LOOKBACK-PERIODS-001.2
- AC-INTEGRATIONS-SENTRY-WATCHER-LOOKBACK-PERIODS-001.3
- AC-INTEGRATIONS-SENTRY-WATCHER-LOOKBACK-PERIODS-001.4
- AC-INTEGRATIONS-SENTRY-WATCHER-LOOKBACK-PERIODS-001.5
- AC-INTEGRATIONS-SENTRY-WATCHER-LOOKBACK-PERIODS-001.6
system_design:
- ../../specs/integrations/system-design/sentry-watcher-lookback-periods.md
---
# Task 01: Honor All Sentry Watcher Lookback Periods
## Summary
Make every offered Sentry lookback token work against the provider by forwarding it only to the issue endpoint whose accepted values include it, keeping the `age:` eligibility constraint on both, guarding the stored value, and correcting the comments that assert the old mechanism.
## In scope
- Add the failing per-endpoint request-shape regression for all five tokens.
- Gate `statsPeriod` on the same project check that selects the issue endpoint, keeping the existing non-empty condition so an empty lookback sends nothing.
- Validate a non-empty lookback token on create and on a filter-carrying update by reusing `parseStatsPeriodUnits` as the single accepted-set authority; do not add a second parser.
- Fail a poll closed when a stored lookback value is outside the syntax.
- Correct the five comments that deny or misstate the mechanism: the `statsPeriodPattern`, `parseStatsPeriodUnits`, `statsPeriodAgeToken`, and `buildIssueQueryString` doc comments in `rest_client.go`, plus the stats-period test comment in `rest_client_test.go`. Three assert the parameter never affects which issues a search returns, and the other two present Kandev's hour/day/week set as Sentry's own relative-duration syntax, which also accepts seconds and minutes.
- Add the service round-trip, update-persistence, rejection, empty-acceptance, unaffected-update, and poll-refusal cases.
- Carry an `@covers AC-INTEGRATIONS-SENTRY-WATCHER-LOOKBACK-PERIODS-001.<n>` anchor on each new test function and on the E2E scenario, matching the traceability guide.
- Pin the offered lookback values in the watch form's unit tests, so the dialog cannot offer a token the backend's write guard rejects.
- Add the Sentry watcher settings E2E scenario for a non-default token.
## Out of scope
- New lookback values or custom durations.
- Unifying the endpoints' accepted sets or their per-issue event-count windows.
- Database migrations, public API field changes, or rewriting stored tokens.
## Acceptance
- A project-scoped request carries `age:-<token>` and no `statsPeriod`; an organization-scoped request carries both, for every offered token; and an empty lookback carries neither, on either path.
- A non-empty lookback token outside the supported hours/days/weeks syntax, for example `30m`, is rejected on create and on a filter-carrying update, while an empty value stays accepted and an untouched stored value never blocks an unrelated update.
- A stored non-default token round-trips through the store and reaches the search client unchanged, while a watch whose stored lookback is outside the syntax fails its poll with a recorded error and creates no tasks.
## Verification
```bash
(cd apps && pnpm install --frozen-lockfile)
(cd apps/backend && go test -v -run 'TestRESTClient_SearchIssues_ForwardsLookbackOnlyWhereAccepted|TestService_CheckIssueWatch_PollsStoredLookbackPeriod|TestService_UpdateIssueWatch_PersistsLookbackPeriod|TestService_IssueWatch_LookbackPeriodValidation' ./internal/sentry)
(cd apps/web && pnpm e2e:run tests/integrations/sentry-settings.spec.ts -- --grep "persists selected lookback period")
(cd apps/web && pnpm e2e:sleep-ratchet)
make fmt
make typecheck
make test
make lint
python3 scripts/list-docs.py validate
```
## Files likely touched
- `apps/backend/internal/sentry/rest_client.go`
- `apps/backend/internal/sentry/rest_client_test.go`
- `apps/backend/internal/sentry/service_issue_watch.go`
- `apps/backend/internal/sentry/service_issue_watch_test.go`
- `apps/web/e2e/tests/integrations/sentry-settings.spec.ts`
- `docs/specs/integrations/requirements/sentry-watcher-lookback-periods.md`
- `docs/specs/integrations/system-design/sentry-watcher-lookback-periods.md`
- `docs/plans/sentry-watcher-lookback-periods/plan.md`
## Dependencies
None.
## Risks
- Dropping the parameter on the project-scoped path without keeping the `age:` term would silently widen every watch search; the request test must assert both.
- Validation must keep accepting an empty lookback: the field is optional, API clients may omit it, and a legacy row may store an empty value, even though the first-party watch form always sends one of the offered tokens.
- The poll guard must stamp the error and return before any search call, or a row with an out-of-syntax token would dispatch tasks for issues of any age.
- The organization-scoped request still depends on that endpoint's accepted set; the request test pins the current contract.
- The E2E mock enforces the window from the filter itself and never sees the outbound request, so it cannot detect this regression alone.
## Parallelism
`sequential`
## Inputs
- `docs/specs/integrations/requirements/sentry-watcher-lookback-periods.md`
- `docs/specs/integrations/system-design/sentry-watcher-lookback-periods.md`
- `apps/backend/internal/sentry/rest_client.go`
- `apps/backend/internal/sentry/service_issue_watch.go`
- `apps/web/e2e/tests/integrations/sentry-settings.spec.ts`
## Results
Implemented in commit `0c82dd006` (5 files, +365/−26): the endpoint-gated `statsPeriod`, the write
guard on create and on a filter-carrying update, the poll-time guard, the five corrected comments,
the four Go tests with `@covers` anchors, and the `persists selected lookback period` E2E scenario.
Verification: the focused Go tests pass (re-run with `-count=1`), the E2E scenario passes (15.5s),
`pnpm e2e:sleep-ratchet` is clean, `make fmt`, `make typecheck`, and `make lint` pass, and the
specification gates pass. `make test` fails only in four packages unrelated to this change that
read this session's ambient `KANDEV_*` variables; the failure reproduces under that environment and
clears with those variables unset. Exact commands and outcomes are in the plan's Verification
results.
Follow-up fix: `Service.SearchIssues` now trims and rejects invalid non-empty lookback periods before
the browse client runs. `TestService_Browse_RejectsInvalidStatsPeriod` covers project- and
organization-scoped service calls, and `TestHTTP_SearchIssues_RejectsInvalidStatsPeriod` covers the
HTTP 400 mapping. The focused command and result are recorded in the plan's follow-up verification.
Review: the design package passed 28 adversarial rounds before implementation, and the implemented
change passed its first implementation round with no findings.
@@ -0,0 +1,45 @@
---
status: draft
system: integrations
created: 2026-09-18
owners:
- kandev
---
# Sentry Watcher Lookback Period Requirements
## Overview
A Sentry lookback period is the selected maximum age of an issue's first-seen time. A Sentry issue watch exposes that selection, and the manual issue browser exposes the same selection through the same backend search path, so this capability covers both surfaces and the integration system owns their provider-facing contract.
## Terminology
- **Lookback period:** The selected maximum age of an issue's first-seen time for a Sentry issue search.
- **Lookback token:** A lookback period in Sentry's relative-duration syntax, one of `1h`, `24h`, `7d`, `14d`, or `30d`.
- **Supported lookback syntax:** An integer from `1` to `3650`, written without a leading zero, followed by `h`, `d`, or `w`, such as `1h`, `7d`, or `2w`. A value in any other form or above that bound, such as `30m`, `0h`, `007h`, `1.5h`, or `9999w`, is outside the syntax.
- **Issue browser:** The manual Sentry issue search dialog.
- **`statsPeriod` request parameter:** An endpoint-specific Sentry window parameter. One issue endpoint accepts only `''`, `24h`, and `14d` there, while another accepts any relative duration and uses it as the request time range.
## Requirements
### REQ-INTEGRATIONS-SENTRY-WATCHER-LOOKBACK-PERIODS-001: Sentry lookback fidelity
**Intent:** A selected lookback must limit matching issues on every Sentry search Kandev issues, and must never be forwarded through a provider parameter that rejects it.
**User story:** As a user configuring a Sentry issue watch, I want each offered lookback period to save and filter matching issues, so that the watch creates tasks from only the intended interval.
#### Acceptance criteria
- **AC-INTEGRATIONS-SENTRY-WATCHER-LOOKBACK-PERIODS-001.1:** When a user creates or updates a Sentry issue watch with any offered lookback token (`1h`, `24h`, `7d`, `14d`, or `30d`), the system shall retain the selected value and limit matching issues to that interval.
- **AC-INTEGRATIONS-SENTRY-WATCHER-LOOKBACK-PERIODS-001.2:** When Kandev issues a Sentry issue search with a selected lookback, the system shall express issue eligibility through Sentry's search query, and shall send that lookback as the `statsPeriod` request parameter only to an endpoint whose accepted values include it.
- **AC-INTEGRATIONS-SENTRY-WATCHER-LOOKBACK-PERIODS-001.3:** When an existing watch stores one of the offered lookback tokens, the system shall continue to load and poll it with that stored interval, without migration or alteration.
- **AC-INTEGRATIONS-SENTRY-WATCHER-LOOKBACK-PERIODS-001.4:** When a create or update request supplies a non-empty lookback value outside Kandev's supported lookback syntax, the system shall reject the request rather than store a search that has no age limit, while an empty or absent lookback remains accepted and applies no age constraint.
- **AC-INTEGRATIONS-SENTRY-WATCHER-LOOKBACK-PERIODS-001.5:** When the issue browser searches with a selected lookback, the system shall limit returned issues to that lookback interval.
- **AC-INTEGRATIONS-SENTRY-WATCHER-LOOKBACK-PERIODS-001.6:** When a stored lookback value is outside Kandev's supported lookback syntax, the system shall fail that watch's poll with a recorded error and create no tasks from it, rather than searching without an age constraint.
## Out of scope
- Adding lookback tokens beyond the offered set, or accepting lookback values that are not whole hours, days, or weeks, such as minutes or seconds.
- Unifying the two issue endpoints' accepted value sets or their per-issue event-count windows.
- Auditing or rewriting lookback values already stored on rows the API does not subsequently write.
- Changing issue-watch filters other than the lookback period.
@@ -0,0 +1,93 @@
---
status: current
system: integrations
requirements:
- REQ-INTEGRATIONS-SENTRY-WATCHER-LOOKBACK-PERIODS-001
---
# Sentry Watcher Lookback Periods System Design
## Purpose and boundaries
The integrations system owns the Sentry issue-watch filter, the manual issue browser, and the outbound Sentry issue search they share. `SearchFilter.StatsPeriod` stays the persisted wire field only because existing settings and rows use it: its product meaning is a lookback token.
Sentry behaves differently per issue endpoint, and `issuesSearchPath` already picks between them:
- The project-scoped endpoint `/projects/{org}/{project}/issues/` accepts only `''`, `24h`, and `14d` in `statsPeriod`, defaults to `24h`, and answers any other value with a 400 (`Invalid stats_period`). A watch poll always reaches this endpoint, because a watch always carries at least one project, and the browser reaches it whenever a project is selected.
- The organization-scoped endpoint `/organizations/{org}/issues/` accepts any `<n><s|m|h|d|w>` value there and uses it as its request time range, defaulting to 90 days when it is absent. The browser reaches this endpoint when it browses an org without selecting a project.
Kandev expresses issue eligibility with the `age:` search term, which accepts `m`, `h`, `d`, and `w` and therefore every offered token, on both endpoints.
The two endpoints also differ in what the parameter sizes:
- On the organization-scoped endpoint it becomes the request time range that reaches the query and the serializer, so it scopes both the issues the search returns and the per-issue `count` and `userCount` the browser renders.
- On the project-scoped endpoint no `start`/`end` window reaches the serializer, so the value sizes only the event series Kandev never parses, and nothing else it reads.
The lookback therefore reaches only the endpoint that accepts it, which fixes the rejected option without narrowing or widening the set of issues any surface returns.
## Requirement mapping
| Requirement | Design section |
| --- | --- |
| `REQ-INTEGRATIONS-SENTRY-WATCHER-LOOKBACK-PERIODS-001` | [Data and contracts](#data-and-contracts), [Failure and recovery](#failure-and-recovery), [Compatibility](#compatibility) |
## Components and responsibilities
- `apps/web/components/sentry/sentry-issue-watch-form.ts` owns the offered lookback tokens for the watch dialogs and sends the selection in the persisted watch filter, while the issue browser keeps its own period list in `sentry-issue-dialog.tsx`. Both lists currently hold the same five tokens, so a token change has to update both — but the lists are not the binding constraint: every offered token must be one `parseStatsPeriodUnits` accepts, a whole number of hours, days, or weeks from 1 to 3650, because a token outside that set is rejected at watch create and on a filter-carrying update.
- `apps/web/components/sentry/sentry-issue-dialog.tsx` sends the browser's selection in the same filter shape, with no project when the user browses an org.
- `apps/web/components/sentry/sentry-issue-watch-table.tsx` renders the stored token in the watch summary.
- `internal/sentry.Service` normalizes and validates the filter, persists it, and passes it unchanged to each per-project search.
- `internal/sentry.RESTClient.searchIssues` builds the provider request through `buildIssueQueryString` and `statsPeriodAgeToken`, and gates the outbound parameter on the same project check that selects the endpoint, for a non-empty lookback only.
- `MockClient` keeps enforcing the same lookback against `FirstSeen` for deterministic settings E2E behavior.
## Data and contracts
No API, wire-field, or schema migration is required: `SearchFilter.StatsPeriod` keeps the values it already holds.
The outbound request contract is:
- `query` includes `age:-<selected-token>` whenever a lookback is selected, on both endpoints; an empty lookback sends neither an age term nor a parameter.
- The `statsPeriod` parameter is sent only when the lookback is non-empty and the filter carries no project, which is the organization-scoped endpoint whose accepted values cover every offered token; the project-scoped endpoint never receives it, and an empty value is never sent, because the organization-scoped endpoint rejects an empty value.
- The accepted lookback set is exactly what `parseStatsPeriodUnits` accepts. The write sites reject anything else (`AC-INTEGRATIONS-SENTRY-WATCHER-LOOKBACK-PERIODS-001.4`) and a stored value outside it fails the poll (`AC-INTEGRATIONS-SENTRY-WATCHER-LOOKBACK-PERIODS-001.6`).
## Control flow
1. A user selects an offered token in the watch dialog or the issue browser.
2. `Service` stores it in `filter_json` (watch) or passes it straight to the search (browser).
3. `SearchIssues` reaches the project-scoped or organization-scoped endpoint, depending on whether a project is present.
4. `buildIssueQueryString` appends `age:-<token>` to the Sentry search query, and the parameter is set only for a non-empty token on the organization-scoped request.
5. Sentry returns only issues within the requested age window; watcher deduplication and task dispatch continue unchanged.
## Failure and recovery
- An offered token always yields an age constraint, so an unsupported value can only arrive through a direct API call or a database write.
- `validateIssueWatchCreate` and the filter branch of `UpdateIssueWatch` are the two write sites. They reject such a value through `parseStatsPeriodUnits`, returning `ErrInvalidConfig` (400); an empty value stays valid because the field is optional. The removed project-scoped parameter was the watch path's only rejection, so this guard and the poll guard below replace it.
- `CheckIssueWatch` fails closed for a row that already stores one, mirroring its existing project-less guard: it stamps the watch error and returns `ErrInvalidConfig` without searching or dispatching. Without that guard, the fix would un-shadow the helper's leave-unfiltered tolerance and let old issues become tasks.
- Validation stays scoped to the request's own filter, mirroring `validateFilterStatuses`: an update that omits the filter never fails on a previously stored value, and untouched rows are not rewritten.
- A request that carries the filter is validated as a whole, and the watch dialog always sends the persisted filter back, so a row storing a value outside the accepted set cannot be saved from that dialog until its lookback is replaced with one of the offered tokens. Editing another field alone is refused; the period selector offers only the accepted tokens, so the stored value cannot be re-sent unchanged.
- `parseStatsPeriodUnits` remains the single accepted-value authority; `statsPeriodAgeToken` and the mock's duration parse both build on it, so the three cannot disagree. Its syntax is narrower than Sentry's own relative-duration grammar, which also accepts seconds and minutes, so a direct caller cannot store a `30m` lookback even though the `age:-30m` term would be valid upstream.
## Persistence
Watch rows keep their stored token. The corrected request shape applies on the next poll, and no row is rewritten. A tick already in flight when the change lands holds no durable state: it reads the dedup set and searches once, then the next tick issues the corrected request.
## Compatibility
- Existing watches keep loading and polling with their stored token.
- The project-scoped issue browse is repaired: selections other than `24h` and `14d` currently fail against Sentry, and now succeed. Its per-issue counts are unaffected, for the reason given under [Purpose and boundaries](#purpose-and-boundaries).
- The organization-scoped issue browse is unchanged: it keeps sending its time range, so its returned issues and the per-issue counts it displays keep their current scope.
- Omitting the parameter on both endpoints was rejected: it would widen the organization-scoped browse's request range to Sentry's 90-day default and change the window its rendered per-issue counts are drawn from, for no gain, since that endpoint accepts every offered token.
- The browse service validates every non-empty lookback before it selects an instance client. A value outside the accepted set returns HTTP 400 for both project-scoped and organization-scoped browse requests, so neither path can silently search without an age limit. Empty values remain accepted. The dialog only sends an offered token, but this guard keeps direct API callers aligned with watcher writes and polls.
- A row that already stores a value outside the accepted set is not rewritten: its polls fail closed and create no tasks. That restores the pre-change behavior, where the removed parameter made every such poll fail at the provider.
## Observability
No new metrics. A failed watch search surfaces as the poller's warning log, and a manual trigger returns the error to its caller; the project-less, instance-resolution, and lookback failures stamp the row, and the settings table renders no error column. Surfacing that recorded error in the Sentry settings table is out of scope here, and the watch-row comments that describe a settings-UI pin or a single stamping caller were already inaccurate before this change and are left uncorrected.
## Test strategy
The request-shape, write-rejection, and poll-refusal tests are the pre-change failures. The stored-value round-trip and the update-persistence tests characterize behavior that already holds. The settings E2E proves retention and reload only, so the request-shape test is the sole provider-compatibility guard. The per-criterion mapping lives in the plan.
## Related decisions
- No ADR: the change restores the existing lookback contract without introducing a new boundary, and the rejected alternative is recorded under [Compatibility](#compatibility).