Replays Self-Serve Bulk Delete System

ai-code-review-evaluation/sentry-greptile#5
Golden recall: 33.3% Extra findings: 80

Golden Comments (1/3 found)

# Comment Severity Status FriendlyReviewer Detail
#1 Using zip(error_ids, events.values()) assumes the get_multi result preserves the input order; dict value order is not guaranteed to match error_ids, so event data can be paired with the wrong ID (missing nodes also shift alignment). LOW ✓ Found "fetch_error_details pairs ids to data via zip(error_ids, events.values()), but get_multi dedups ids (dict.fromkeys) and appends cache-only ids at the end, so dict order does not reliably match input order. An ErrorEvent can be built with the wrong id/title/message/timestamp, producing incorrect error context sent to Seer." (project_replay_summarize_breadcrumbs.py L118). Golden rated Low in the dataset; FriendlyReviewer upgraded it to HIGH.
#2 Breaking changes in error response format MEDIUM ✗ Not found The judge found that FriendlyReviewer covers browser_reporting_collector.py but not the response format: it flags the unauthenticated request-body logging (L93), the 0-valued validation (L55) and the untested 422 — not the contract change (empty 422 → JSON body {"error": ..., "details": ...}). The browser-reporting zone is moreover delivered in prose (partial flag). Location: browser_reporting_collector.py:116.
#3 Detector validator uses wrong key when updating type MEDIUM ✗ Not found The judge found that FriendlyReviewer covers validators/base/detector.py (non-atomic update() L88, owner audit L90) but not the wrong key name: instance.type = validated_data.get("detector_type", instance.group_type).slug while the serializer field is called type. No candidate mentions detector_type. Location: detector.py:64.

Supplementary Findings (80 findings)

Finding File Severity Legitimate?
Blob-deletion ThreadPoolExecutor swallows worker exceptions — orphaned blobs, no retry delete.py:75 HIGH ✓ Real: failed blob delete silently dropped, archive event still published, job marked completed
drain_mailbox_parallel taskworker deadline (120s) shorter than its own internal loop deadline (180s) deliver_webhooks.py:239 HIGH ✓ Real: parallel task killed before its internal stop condition; inconsistent with the 300s sequential path
check_auth 60s deadline with last_synced advanced upfront — interrupted batch drops identities silently check_auth.py:77 HIGH ✓ Real: unverified remainder not re-selected for ~24h (AUTH_CHECK_INTERVAL)
delayed_workflows Redis buffer read-process-delete TOCTOU — re-fires actions on retry delayed_workflow.py:652 HIGH ✓ Real: no atomic claim/lease; Retry + deadline re-processes fired actions
use-table-widget-visualization flag renders a hardcoded empty table, discarding real query results chart.tsx:173 HIGH ✓ Real: functional regression the moment the flag is enabled
Fresh ThreadPoolExecutor(max_workers=100) per row — deadline overruns on large max_segment_id delete.py:74 MEDIUM ✓ Real: up to limit executors serially, concurrency limited to a single replay
No replay bulk-delete option registered in defaults.py — feature ships ungated options/defaults.py:2627 MEDIUM ✓ Real: no replay-bulk-delete match anywhere; no rollout gating
sentry:autofix_automation_tuning plain default= silently changes the value for existing projects projectoptions/defaults.py:205 MEDIUM ✓ Real: no epoch_defaults, so projects that never set the option are affected
Collector test coverage regression: logger assertion deleted, JSON parser path and 422 branch untested test_browser_reporting_collector.py:68 MEDIUM ✓ Real: BrowserReportsJSONParser path unexercised, no positive media-type test
Logs full request body from an unauthenticated, @csrf_exempt, open-CORS endpoint — injection/metric-inflation surface browser_reporting_collector.py:93 MEDIUM ✓ Real: no rate limit on the logging path
Dead code left after ParameterizationRegexExperiment removal parameterization.py:338 MEDIUM ✓ Real: _handle_regex_match never called, compiled_pattern unused
(?x) duration pattern: leading \b applies only to the ms branch — over-matches parameterization.py:158 MEDIUM ✓ Real: "1234msfoo" matches; inconsistent with every other pattern
schedule_webhook_delivery 30s deadline can be killed under load deliver_webhooks.py:85 MEDIUM ✓ Plausible: up to 1000 records, each with replica read + primary UPDATE; siblings use 60-120s+
except Exception sets is_valid = False but the flag write is skipped when already linked check_auth.py:149 MEDIUM ✓ Real: sso:invalid never set in the common case — security rationale not honored
update() not atomic — audit runs after commit, outside any transaction detector.py:88 MEDIUM ✓ Real: failed audit leaves a silent detector update
Owner changes persisted but audit payload returns only {"name": ...} detector.py:90 MEDIUM ✓ Real: ownership change invisible in the audit log
workflows_to_envs[workflow_id] can raise KeyError on deleted workflow — aborts whole batch delayed_workflow.py:672 MEDIUM ✓ Real: buffered event referencing a deleted workflow crashes processing
All tests @patch(... "make_seer_request") — signed request never exercised test_project_replay_summarize_breadcrumbs.py:58 MEDIUM ✓ Real: URL, content-type and Rpcsignature contract untested
on_results merges the full error list into every page — errors re-summarized N times project_replay_summarize_breadcrumbs.py:100 MEDIUM ✓ Real: pagination multiplies summarization work
'Project not found' guard fires while ProjectsStore is still loading ai/index.tsx:86 MEDIUM ✓ Real: hard error flash on deep-link before the spinner
should_create_group deletes the wrong Redis key — noise-reduction counter never reset grouptype.py:696 MEDIUM ✓ Real (pre-existing): deletes raw grouphash instead of grouphash:{project.id}
analytics.record before the feature-flag gate — rejected requests also recorded organization_preprod_artifact_assemble.py:81 MEDIUM ✓ Real: any caller with release permission can inflate metrics
No-values-for-numbers short-circuit guards on hook type prop, but both callers pass 'string' useGetTraceItemAttributeValues.tsx:88 MEDIUM ✓ Real: optimization never triggers for numeric attributes; spec passes with a type no caller uses
'Regenerate' is a no-op on the truncated view — full token never shown again addUploadToken.tsx:36 MEDIUM ✓ Real: showTokenDetails already true; showFullToken stays false
Alert promises 'copy to a safe place' but there is no copy button anywhere addUploadToken.tsx:73 MEDIUM ✓ Real: no clipboard action in the component
HIDDEN_ATTRIBUTES filter bypassed when there is no search query attributes.tsx:65 MEDIUM ✓ Real: internal fields render by default — what the constant was introduced to prevent
Flex refactor dropped align-items: center and gap on tabs/vitals container traceTabsAndVitals.tsx:93 MEDIUM ✓ Real: Flex defaults to stretch — layout regression
Flex refactor dropped height: 100% and changed flex-basis on the waterfall wrapper traceWaterfall.tsx:712 MEDIUM ✓ Plausible: subtle sizing change; flagged for visual verification, not proven
Flex refactor dropped overflow: hidden — long attachment names stop ellipsizing eventAttachments.tsx:143 MEDIUM ✓ Real: reverts to min-content width of the full nowrap filename
flex: 1 1 0% replaced by <Flex.Item grow={1}> which keeps flex-basis: auto integrationLayout.tsx:247 MEDIUM ✓ Real: column base size now content-derived, can overflow or shift the Metadata column
Debounced function recreated on every render — one network request per keystroke transactionNameSearchBar.tsx:130 MEDIUM ✓ Real: debounce timer resets each render; leading: true fires per keystroke
No cancellation/ordering guard — stale responses can render; unhandled promise rejection on fetch failure transactionNameSearchBar.tsx:152 MEDIUM ✓ Real: race condition plus re-thrown error with no .catch() or user-facing state
has_more off-by-one — one extra chained run fetching zero rows delete.py:170 LOW ✓ Real, minor: wasted Snuba round-trip, inaccurate 'has more' signal
Comment typo: '# deleting the segements.' delete.py:91 LOW ✓ Real typo, cosmetic
max_segment_id: None test mocks delete_matched_rows — the new None guard is never invoked test_delete_replays_bulk.py:97 LOW ✓ Real: the core fix has no direct test coverage
snuba-metrics uses odd containerized-metrics-dev mode naming devservices/config.yml:26 LOW ✓ Worth confirming against the snuba repo that the mode is valid
0-valued age/timestamp not detected (truthiness check) browser_reporting_collector.py:55 LOW ✓ Real edge case: 0 bypasses validation
matches_counter regex path has no test assertions parameterization.py:282 LOW ✓ Real: counting path for metrics/analytics unverified
format_comment_subtitle now completely unused (zero call sites) github/integration.py:394 LOW ✓ Real dead code after template consolidation
GitLab suite lacks missing-entity coverage for the shared pr_comment_workflow task test_pr_comment.py:393 LOW ✓ Real: shared-handler regressions unprotected on the GitLab side
Template ends with hard \n — both providers produce \n\n between issues commit_context.py:143 LOW ✓ Real, minor: couples template to the join strategy
Discarded webhooks (attempts >= MAX_ATTEMPTS) tallied as delivered deliver_webhooks.py:205 LOW ✓ Real: delivered counter does not reflect true deliveries
check_authcheck_auth_identities ping-pong with no delay — busy loop while stale identities remain check_auth.py:99 LOW ✓ Real: tasks re-enqueue as fast as scheduling allows
no_slow_condition_groups branch and full buffer pipeline untested delayed_workflow.py:652 LOW ✓ Real: buffer-key deletion on success unverified end-to-end
Detector index endpoint uses self.paginate(...) but has no pagination test test_organization_detector_index.py:43 LOW ✓ Real: per-page limits, cursors and Link headers uncovered
No-op migrations 0917/0920 retain misleading tables hints 0917_convert_org_saved_searches_to_views.py:42 LOW ✓ Harmless but misleading
'AT MOST 55 words' / 'AT MOST two sentences' never enforced server-side feedback_summaries.py:52 LOW ✓ Real: over-length summary cached and served verbatim
Whitespace-only response normalized to "" — empty summary persisted feedback_summaries.py:44 LOW ✓ Real: if response: guard bypassed by normalization
Boolean(normalizedDateRange) always true — the date-range guard is a no-op useFeedbackSummary.tsx:40 LOW ✓ Real: normalizeDateTimeParams returns an object
Final return {summary: data.summary, ...} dereferences data without a guard useFeedbackSummary.tsx:63 LOW ✓ Real: useApiQuery can return undefined in fall-through states
Multi-error and missing-nodestore-node paths unverified (single event per test) test_project_replay_summarize_breadcrumbs.py:139 LOW ✓ Real: such a test would have surfaced the zip bug
?field=id drops error_ids — error context silently disabled project_replay_summarize_breadcrumbs.py:84 LOW ✓ Real: field-stripping interaction undocumented
error_events.sort(...) mutates the list shared across pagination pages project_replay_summarize_breadcrumbs.py:136 LOW ✓ Real, currently idempotent — input mutation footgun
Same-second errors ordered after the equal-timestamp event (strict <) project_replay_summarize_breadcrumbs.py:152 LOW ✓ Real edge case with coarse recording-segment timestamps
Stale get docstring, dead ErrorEvent["category"], silently swallowed nodestore failures project_replay_summarize_breadcrumbs.py:53 LOW ✓ Real cleanups
Zoom event schemas loose Record<string, unknown> — no distinguishing payload replayAnalyticsEvents.tsx:116 LOW ✓ Real: tracking calls only pass {organization}
Zoom tracking fires even when the scale is clamped to the current value timeAndScrubberGrid.tsx:35 LOW ✓ Real edge case: maxScale shrinking between renders
chapterData computes end per chapter but the render loop never consumes it ai/index.tsx:124 LOW ✓ Real dead field
Event type defined once on the class and re-specified as a raw literal at the call site preprod/analytics.py:5 LOW ✓ Real drift risk
Star import re-exports analytics name into the sentry.preprod namespace preprod/__init__.py:1 LOW ✓ Real: latent source of confusion
find_missing_chunks(...) runs before the cached-status check — NOT_FOUND on completed assembly organization_preprod_artifact_assemble.py:102 LOW ✓ Plausible divergence from organization_release_assemble.py; semantics to confirm
Misleading 'The chunks field is required' message for a single malformed item organization_preprod_artifact_assemble.py:48 LOW ✓ Real: e.path[0] is "chunks" for item-level errors
update_org_auth_token_last_used(...) only reached on the happy path organization_preprod_artifact_assemble.py:133 LOW ✓ Minor: intended semantics to verify
Chart-type tie resolves to LINE (last element of AREA→BAR→LINE) visualizes.tsx:187 LOW ✓ Latent: never triggers today — single-element array only
this.stack = 'all' unconditionally — multiple bar overlays stack on one another visualizes.tsx:49 LOW ✓ Plausible visible change; UX to confirm
Visualize.fromJSON returns an array despite a singular name visualizes.tsx:78 LOW ✓ Real footgun, callers must .map()
queryExtras exposed on the options interface but silently discarded useExploreAggregatesTable.tsx:23 LOW ✓ Real: interface lies to callers
groupBys memoized from columns prop, not the live tempColumns state aggregateColumnEditorModal.tsx:63 LOW ✓ Real: stale drop-down lists during the edit session
isVisualize requires a string yAxis — raw BaseVisualize forwarded verbatim aggregateColumnEditorModal.tsx:66 LOW ✓ Real fragility, low impact; worth documenting
A/B/C chart labels discarded on toJSON() — chart identity never persisted toolbarVisualize.tsx:47 LOW ✓ Design risk for future saved-query features, consciously documented
Fixture rows carry id: '' not present in meta.fields — never rendered sampleHTTPRequestTableData.ts:8 LOW ✓ Minor fixture hygiene
Dead if (!tableData?.meta) fallback branch in the cell renderer defaultTableCellRenderers.tsx:76 LOW ✓ Real dead code; optional-chaining on a required prop nearby
Unchecked casts across differing types (AttributeValueType | undefined vs ColumnValueType) defaultTableCellRenderers.tsx:35 LOW ✓ Real type-safety gap, tolerated downstream today
ResizeObserver callback calls setContainerWidth with no functional guard traceTabsAndVitals.tsx:46 LOW ✓ Real: needless re-renders on height/count observer fires
Responsive hidden-xs hidden-sm class moved inside the TextOverflow wrapper times.tsx:37 LOW ✓ Real: empty wrapper item remains on small screens
User-authored transaction name interpolated into the search token without escaping transactionNameSearchBar.tsx:174 LOW ✓ Real: " or search syntax breaks the constructed MutableSearch
ArrowUp from an empty selection wraps to count - 2, skipping the last suggestion transactionNameSearchBar.tsx:92 LOW ✓ Real off-by-one
Assertion inspects only the first interval bucket — zero-fill of later buckets unverified test_organization_events_stats.py:3184 LOW ✓ Real: passes only because all logs land in the first hour
Hardcoded Other bucket count in the top-events assertion test_organization_events_stats.py:3189 LOW ✓ Real: brittle to fixture changes
Logs top-events class only exercises yAxis="count()" — no meta assertion, no zero-fill check test_organization_events_stats.py:3150 LOW ✓ Real coverage gap vs the spans sibling class

Honest Analysis

The benchmark result is a 1/3 golden recall (33.3%), and the score sits on unfair ground. The fork PR is a composite diff ("mix of many PRs", 106+ files) that happens to contain the files of the 3 golden comments (browser_reporting_collector.py, workflow_engine/.../detector.py, project_replay_summarize_breadcrumbs.py) while being nominally about a completely different feature, the Replays Self-Serve Bulk Delete System. The single hit is the zip(error_ids, events.values()) pairing bug, found at the exact location (L118) with the exact mechanism (get_multi dedup reordering) — a direct match, and FriendlyReviewer upgraded it from dataset LOW to HIGH with the full consequence spelled out (wrong id/title/message/timestamp sent to Seer).

The 2 misses are instructive: they are not files FriendlyReviewer skipped. On browser_reporting_collector.py, FR flagged the unauthenticated request-body logging (L93), the 0-valued validation (L55) and the untested 422 branch — but not the contract change to the error response format (empty 422 → JSON body {"error": ..., "details": ...}). On detector.py, FR covered the non-atomic update() (L88) and the missing owner-change audit (L90) — but not the wrong key name detector_type vs the serializer field type. Both golden bugs are adjacent to what FR did find, in the same files, at nearby lines. That is the pattern the benchmark report calls "wrong angle, not absence of coverage" — real, but it does not change the score. Adding to the caveats: the browser-reporting zone was delivered in prose with a partial flag (FR itself admits reduced verification there), which plausibly contributed to the response-format miss.

The benchmark only credits golden matches, and this diff carried no goldens for the feature it is actually about. FriendlyReviewer found 5 more real high-severity defects that no golden covers — the blob-delete path swallowing worker exceptions and leaving orphaned blobs with no retry (the core requirement!), the drain_mailbox_parallel 120s deadline shorter than its own 180s internal loop, check_auth advancing last_synced for an interrupted 60s batch, the TOCTOU on the delayed_workflows Redis buffer, and the use-table-widget-visualization flag rendering a hardcoded empty dashboard table — plus the found zip golden, for 6 high findings in total. On a real merge decision, several of these are more important than the 2 missed mediums. The honest counterweight: 81 findings on one PR is also a symptom of reviewing a messy composite diff; a large share of the low bucket is genuine noise-adjacent (typos, dead fields, fixture nits, latent tie-break rules that never trigger today), and the 27 mediums lean heavily on plausible-but-unproven layout regressions from the Flex refactor.

Overall: this page is a fair picture of what a golden-based benchmark measures versus what a review actually delivers. Measured by Martian Code goldens, the run is weak (1/3). Measured by the value of the review on this diff — the bulk-delete thread-pool bug alone is a silent data-loss path in the PR's core feature — FR produced materially useful work that the metric does not see. Both statements are true, and we would not want either one hidden.

FriendlyReviewer's full review

As posted on the PR — the complete output from FriendlyReviewer.

friendly-reviewer bot commented now

Hi there 👋

🌥️ Tech 🌤️ Feat
6 high, 27 medium, 48 low 1 mitigated

An unusually broad Merge Request: the headline requirement is the Replays Self-Serve Bulk Delete System, but the diff also bundles many unrelated changes (an autofix-automation default flip, a browser-reporting endpoint refactor, grouping-parameterization cleanup, a GitHub/GitLab PR-comment template consolidation, taskworker deadline additions across deliver_webhooks/check_auth, a workflow-engine detector refactor, placeholder migrations, replay breadcrumb-summarization, and a large Explore dashboards/insights UI migration).

Note on review completeness: one portion of this review was delivered from prose without a final submission call and is flagged partial — the browser-reporting collector findings are valid but were less fully verified than the others. Please treat that area with slightly more scrutiny.

Overall the MR is directionally correct but not ready to merge. Multiple high-severity issues were found:

  • Replay bulk-delete (the core requirement): the ThreadPoolExecutor blob-deletion path swallows worker exceptions, leaving orphaned blobs with no retry; no replay bulk-delete option is registered for rollout gating; and the max_segment_id=None fix is never exercised by tests.
  • Dashboard flag: the use-table-widget-visualization flag renders a hardcoded empty table, discarding real query results the moment the flag is enabled.
  • Webhooks: drain_mailbox_parallel gets a 120s taskworker deadline shorter than its own 180s internal loop deadline.
  • Auth sync: the new 60s deadline can interrupt a batch mid-way while last_synced was already advanced, silently dropping identities from the sync for ~24h.
  • Workflow engine: the Redis buffer read-process-delete path has a TOCTOU window that can re-fire actions on retry, and update() is non-atomic with audit-after-commit.
  • Replay breadcrumbs: fetch_error_details pairs event IDs to nodestore data via zip(error_ids, events.values()), which is unreliable given get_multi dedup/caching behavior.
  • grouptype: should_create_group deletes the wrong Redis key so the noise-reduction counter is never reset.

Lower-severity but widespread issues include several Flex-refactor layout regressions (integrationLayout flex-basis, traceTabsAndVitals alignment, traceWaterfall height, eventAttachments ellipsis), a debounce in the trace-name search bar that never coalesces, dead code left after the ParameterizationRegexExperiment removal, audit-log gaps for owner changes and preprod analytics recording before gating, plus multiple test-coverage gaps. A large fraction of the file set is clean (config registries, migrations no-ops, parameterization core, table-widget fixtures, explore page-params parity, project test updates).

Status: REQUEST_CHANGES — several findings are blocking for the core requirement and multi-file robustness concerns.


These might need a close look

  • 🔴 src/sentry/replays/usecases/delete.py (L75)
    pool.map(_delete_if_exists, ...) results are never consumed and ThreadPoolExecutor.__exit__ only waits; combined with _delete_if_exists catching only NotFound, transient storage/network errors are silently swallowed. A failed blob delete is dropped, the Kafka archive event is published, and the task marks the job completed — leaving orphaned blobs that are never retried. Consume the iterator (e.g. as_completed) so worker failures trigger the retry policy.
  • 🔴 src/sentry/hybridcloud/tasks/deliver_webhooks.py (L239)
    drain_mailbox_parallel gets processing_deadline_duration=120, but both drain paths set their internal loop deadline to BATCH_SCHEDULE_OFFSET = 180s. The 120s taskworker deadline is shorter than the task's own 180s loop deadline, so a parallel task is killed by the taskworker before reaching its internal stop condition — the internal deadline logic is unreachable and batches can be abandoned/retried while still deliverable, and the parallel path is inconsistent with the sequential path (300s). Raise the parallel deadline to ≥180s or shorten the internal deadline.
  • 🔴 src/sentry/tasks/auth/check_auth.py (L77)
    check_auth_identities now has processing_deadline_duration=60 and processes its whole chunk_size=100 batch synchronously with outbound SSO refresh network I/O per item. If the worker is interrupted mid-batch, check_auth (line 52) already advanced last_synced=now for the entire batch, so the un-verified remainder won't be selected again for ~24h (AUTH_CHECK_INTERVAL) — the sync is silently incomplete. Shrink the effective batch per task or only advance last_synced for identities actually verified.
  • 🔴 src/sentry/workflow_engine/processors/delayed_workflow.py (L652)
    process_delayed_workflows reads the entire Redis buffer, fires actions, and only afterwards deletes keys — there is no atomic claim/lease on the buffer. The new Retry(times=5, delay=5) + 60s deadline means a run exceeding the soft time limit after firing but before cleanup re-reads and re-fires the same actions; two workers have the same TOCTOU window. filter_recently_fired_workflow_actions narrows but doesn't eliminate this. Claim the batch atomically (e.g. Redis Lua SMOVE/RENAME to a processing key, or a short TTL lease) before firing.
  • 🔴 src/sentry/replays/endpoints/project_replay_summarize_breadcrumbs.py (L118)
    fetch_error_details pairs ids to data via zip(error_ids, events.values()), but get_multi dedups ids (dict.fromkeys) and appends cache-only ids at the end, so dict order does not reliably match input order. An ErrorEvent can be built with the wrong id/title/message/timestamp, producing incorrect error context (and wrong chronological interleaving) sent to Seer. Look each node id up explicitly: for event_id, node_id in zip(error_ids, node_ids): data = events.get(node_id); ....
  • 🔴 static/app/views/dashboards/widgetCard/chart.tsx (L173)
    When use-table-widget-visualization is enabled, TableWidgetVisualization is rendered with columns={[]} and an empty tableData even though real results are in scope (result.data, result.meta, derived fields/fieldAliases). Enabling the flag renders a completely empty dashboard table — a functional regression. Either wire the real data in or don't gate the component behind an enforceable flag until it's functional.

Worth checking

  • 🟡 src/sentry/replays/usecases/delete.py (L74)
    delete_matched_rows spins up a fresh ThreadPoolExecutor(max_workers=100) per row sequentially (up to limit executors serially), so only segments within a single replay are deleted concurrently. For large max_segment_id values this can blow past processing_deadline_duration=300, forcing repeated retries. Consider a single shared pool for the whole batch.
  • 🟡 src/sentry/options/defaults.py (L2627)
    No replay bulk-delete option is registered anywhere (searched replay-bulk-delete — no matches). The bulk-delete feature ships fully ungated (no option/feature-flag guard). If this is meant to be safely rollout-gated, an option following the standard register(...) pattern is missing. (The only defaults.py change — removing grouping.experiments.parameterization.traceparent — is verified safe/unreferenced.)
  • 🟡 src/sentry/projectoptions/defaults.py (L205)
    sentry:autofix_automation_tuning default change ("low""off") uses a plain default= with no epoch_defaults, so per WellKnownProjectOption.get_default it silently changes the effective value for existing projects that never set the option, not just new ones. If only the forward-looking default is intended, register with epoch_defaults (bump LATEST_EPOCH). The coordinated change to constants.py/serializers/_is_issue_fixable is otherwise coherent.
  • 🟡 tests/sentry/api/endpoints/test_browser_reporting_collector.py (L68)
    Test-coverage regression for the collector endpoint: (a) the old test_logs_request_data_if_option_enabled (patching logger.info and asserting browser_report_received) was deleted — there is now no assertion that the option-enabled endpoint logs, and test_404s_by_default does not assert that no logging/metrics occur when disabled; (b) no positive test posts with application/reports+json, so the BrowserReportsJSONParser path is unexercised (only the invalid-content-type failure case is); (c) the 'body must be a list' 422 branch is untested. Restore the logger/metrics gating assertions and add media-type/array-validation tests.
  • 🟡 src/sentry/issues/endpoints/browser_reporting_collector.py (L93)
    logger.info("browser_report_received", extra={"request_body": request.data}) logs the complete request body from an unauthenticated (permission_classes = ()), @csrf_exempt, open-CORS endpoint with no rate limit — a log/JSON injection and metric-inflation abuse surface. Truncate/sanitize the logged body (or log a bounded summary) and consider a rate limit.
  • 🟡 src/sentry/grouping/parameterization.py (L338)
    Dead code left after removing ParameterizationRegexExperiment: _handle_regex_match (lines 338-345) is defined but never called (the only loop body calls experiment.run(...)), and ParameterizationRegex.compiled_pattern (lines 41-48) is now unused (only consumer was the removed experiment). Remove both and their now-dangling re/re.Pattern usage — keeping them misleadingly suggests regex experiments are still supported.
  • 🟡 src/sentry/grouping/parameterization.py (L158)
    The duration pattern \b(\d+ms) | (\d+(\.\d+)?s)\b is compiled with (?x) which ignores the unescaped whitespace, yielding \b(\d+ms)|(\d+(\.\d+)?s)\b — the leading \b applies only to the ms branch and the trailing \b only to the s branch. "1234msfoo" over-matches, and the semantics are inconsistent with every other pattern. Use a single group, e.g. \b(?:\d+ms|\d+(?:\.\d+)?s)\b, with positive/negative tests.
  • 🟡 src/sentry/hybridcloud/tasks/deliver_webhooks.py (L85)
    schedule_webhook_delivery loops over up to BATCH_SIZE=1000 records, each doing a replica read plus a primary UPDATE of up to MAX_MAILBOX_DRAIN=300 rows. With processing_deadline_duration=30 the tick can be killed under load; also scheduled_mailboxes.count() on line 134 evaluates the full annotated queryset before the [:BATCH_SIZE] slice. Confirm 30s suffices for the worst case or raise it (siblings use 60-120s+).
  • 🟡 src/sentry/tasks/auth/check_auth.py (L149)
    The except Exception branch sets is_valid = False with the comment 'to ensure security we count any kind of error as an invalidation event', but the flag write only runs when sso:linked != is_linked; since is_linked=True here, the common already-linked case skips the write and sso:invalid is never set. The security rationale in the comment is not honored. Drive the flag update off sso:invalid/is_valid too.
  • 🟡 src/sentry/workflow_engine/endpoints/validators/base/detector.py (L88)
    update() is not atomic and audits after commit: instance.save() (line 88) commits first, then create_audit_entry(... DETECTOR_EDIT ...) (line 90) runs outside any transaction. If audit creation fails, the detector is silently updated with no audit trail. Wrap the save + audit in the same transaction.atomic(router.db_for_write(Detector)) block used by create().
  • 🟡 src/sentry/workflow_engine/endpoints/validators/base/detector.py (L90)
    Owner changes are persisted but the audit payload is data=instance.get_audit_log_data() which returns only {"name": ...}. An admin cannot tell from the audit log that ownership changed or who owned it before/after. Extend get_audit_log_data() (and/or the audit event) to include owner_user_id/owner_team_id.
  • 🟡 src/sentry/workflow_engine/processors/delayed_workflow.py (L672)
    In both get_condition_query_groups (line 672) and get_groups_to_fire (line 419), workflow_env = workflows_to_envs[workflow_id] if workflow_id else None can raise KeyError when a workflow referenced by a buffered event was deleted (since fetch_workflows_envs only returns existing workflows), aborting the whole batch. Use workflows_to_envs.get(workflow_id) and skip/log when absent.
  • 🟡 tests/sentry/replays/test_project_replay_summarize_breadcrumbs.py (L58)
    All tests @patch(... "make_seer_request"), so the signed request is never exercised. Add a test with a mocked requests.post asserting the URL (SEER_AUTOFIX_URL/v1/automation/summarize/replay/breadcrumbs), the content-type header, and that the Rpcsignature is computed over the exact request body (and only present when the shared secret applies).
  • 🟡 src/sentry/replays/endpoints/project_replay_summarize_breadcrumbs.py (L100)
    on_results=functools.partial(analyze_recording_segments, error_events) fires per pagination page and merges the full error_events list into every page's logs, repeating all error messages across pages and re-summarizing the same errors N times. Disable pagination here or distribute errors only to the page containing their timestamped segment.
  • 🟡 static/app/views/replays/detail/ai/index.tsx (L86)
    The 'Project not found' guard runs before the isPending branch and fires whenever project is undefined — but useProjectFromId returns undefined both when the project doesn't exist and while the async ProjectsStore is still loading (e.g. deep-link). The AI tab can flash a hard error then swap to the spinner. Gate on the project-store being loaded (initiallyLoaded/fetching).
  • 🟡 src/sentry/issues/grouptype.py (L696)
    Pre-existing (adjacent to the added noise_config): should_create_group increments key = f"grouphash:{grouphash}:{project.id}" but on threshold-cross deletes client.delete(grouphash) — the raw fingerprint string, not the incremented key — so the noise-reduction counter is never reset. Fix to client.delete(key). The newly-added NoiseConfig(ignore_limit=5) itself is correct and consistent with the registry.
  • 🟡 src/sentry/preprod/api/endpoints/organization_preprod_artifact_assemble.py (L81)
    analytics.record("preprod_artifact.api.assemble", ...) is the first statement in post(), before the feature-flag gate (404) and schema validation (400), so every rejected/not-found request also emits an analytics event — any authenticated user with ProjectReleasePermission can inflate metrics. Move the record after the gates, or document that all attempts are intentionally recorded.
  • 🟡 static/app/views/explore/hooks/useGetTraceItemAttributeValues.tsx (L88)
    The no-values-for-numbers short-circuit guards on the hook's type prop, but both production callers set type: 'string'; numeric attributes (tag kind MEASUREMENT) reach this function with the string-typed hook and fall through to issue a request with attributeType: 'string' — the opposite of the intended optimization. The spec only passes because it constructs the hook with type: 'number', which matches no real caller. Gate on the tag's kind (e.g. tag.kind === FieldKind.MEASUREMENT) and update the spec accordingly.
  • 🟡 static/app/views/codecov/tests/onboardingSteps/addUploadToken.tsx (L36)
    handleGenerateClick only calls setShowTokenDetails(true); on the truncated view showTokenDetails is already true, so 'Regenerate' (line 92) is a no-op — showFullToken stays false and the full token can never be shown again after 'Done' without a reload. Reset setShowFullToken(true) (and ideally generate a distinct token value rather than reusing FULL_TOKEN).
  • 🟡 static/app/views/codecov/tests/onboardingSteps/addUploadToken.tsx (L73)
    The alert says 'Please copy this token to a safe place - it won't be shown again.' but there is no copy button or clipboard action anywhere in the component — the copy step implied by the task flow is missing. Add a copy action beside the full-token CodeSnippet, or flag with a TODO if this is still a hardcoded sample.
  • 🟡 static/app/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/attributes.tsx (L65)
    HIDDEN_ATTRIBUTES (['is_segment', 'project_id', 'received']) is only applied inside the non-empty-search branch; when there's no query the early return sorted; bypasses the filter, so these internal fields still render by default — exactly what the constant was introduced to prevent. Make the exclusion unconditional and independent of searchQuery.
  • 🟡 static/app/views/performance/newTraceDetails/traceTabsAndVitals.tsx (L93)
    The Flex refactor dropped align-items: center and gap: ${space(1)} from the tabs/vitals container. The Flex component defaults to align-items: stretch, so Tabs and TraceContextVitals now stretch vertically instead of being centered and spacing is no longer guaranteed. Add align="center" and gap={space(1)} to preserve the layout.
  • 🟡 static/app/views/performance/newTraceDetails/traceWaterfall.tsx (L712)
    The Flex refactor dropped height: 100% (and changed flex-basis: autoflex-basis: 0%) on the waterfall wrapper. flex: 1 should still grow in the parent flex column, but the flex-basis change is a subtle sizing behavior change for the TraceGrid inside. Visually verify the waterfall (and issuesTraceWaterfall.tsx variant) still fills the available height; add height: '100%' if there's any scroll/fill regression.
  • 🟡 static/app/components/events/eventAttachments.tsx (L143)
    The Flex refactor dropped overflow: hidden from the attachment name-cell wrapper. Old FlexCenter grid cell carried overflow: hidden, letting the 1fr first column shrink so Name's nowrap + text-overflow: ellipsis truncates. The new <Flex align="center"> has default overflow: visible, reverting to min-content width of the full nowrap filename — long attachment names will stop ellipsizing. Constrain the cell (min-width: 0/overflow: hidden).
  • 🟡 static/app/views/settings/organizationIntegrations/detailedView/integrationLayout.tsx (L247)
    flex: 1 (shorthand → flex: 1 1 0%) was replaced by <Flex.Item grow={1}>, which per the FlexItem implementation only sets flex-grow: 1 while keeping flex-basis: auto. The Description/FeatureList column's base size is now derived from content rather than remaining space, which can overflow or shift the Metadata column for wide content. Use <Flex.Item flex={1}>/basis={0} to preserve flex-basis: 0%, or confirm auto is acceptable.
  • 🟡 static/app/views/insights/pages/transactionNameSearchBar.tsx (L130)
    const getSuggestedTransactions = debounce(async query => {...}, DEFAULT_DEBOUNCE_DURATION, {leading: true}) is recreated on every render (not wrapped in useCallback/useMemo). Each keystroke calls setSearchString (re-render) then the debounced fn, so the debounce timer resets every keystroke and with leading: true fires one network request per keystroke ≥3 chars. Memoize the debounced function.
  • 🟡 static/app/views/insights/pages/transactionNameSearchBar.tsx (L152)
    With requests firing on every keystroke (no cancellation/ordering guard), an older query's response can resolve after a newer one and setSearchResults renders stale suggestions (race condition). Also catch (_) { throw new Error(...) } re-throws inside the async debounced fn invoked without .catch() in handleSearchChange, producing an unhandled promise rejection on any fetch failure with no user-facing error state. Track a request sequence/AbortController and surface a graceful error instead of throwing.
Small things (take or leave)
  • 🔵 src/sentry/replays/usecases/delete.py (L170)
    has_more = len(rows) == limit reports True when total matches are an exact multiple of the limit, scheduling one extra chained run that fetches zero rows. Not incorrect (offset advances monotonically), but it adds a wasted Snuba round-trip and an inaccurate 'has more' signal.
  • 🔵 src/sentry/replays/usecases/delete.py (L91)
    Comment typo: # deleting the segements. should read segments.
  • 🔵 tests/sentry/replays/tasks/test_delete_replays_bulk.py (L97)
    test_run_bulk_replay_delete_job_completion passes max_segment_id: None but mocks delete_matched_rows, so the new None guard in _make_recording_filenames is never invoked — the core fix has no direct test coverage. Add a direct unit test of delete_matched_rows/_make_recording_filenames with both None and an integer value.
  • 🔵 devservices/config.yml (L26)
    The new snuba-metrics dependency uses mode: containerized-metrics-dev, an odd naming divergence from the existing containerized/containerized-profiles modes. Confirm against the snuba repo that this is a valid mode the snuba devservices tooling accepts, otherwise the tracing mode will fail to bring up the metrics stack.
  • 🔵 src/sentry/issues/endpoints/browser_reporting_collector.py (L55)
    validate_timestamp/validate_age check the other field via truthiness (if self.initial_data.get("age")), so a 0 value for the other field is not detected and a report carrying both fields passes validation. Use "age" in self.initial_data / "timestamp" in self.initial_data and add a 0-valued edge-case test.
  • 🔵 src/sentry/grouping/parameterization.py (L282)
    matches_counter — the only signal consumed by normalize_message_for_grouping for the grouping.value_trimmed_from_message metrics and get_successful_experiments analytics — has no test assertions for the regex path (correct keys/counts) nor for the experiment-gating negative case. The deletion of test_parameterize_regex_experiment* further reduces coverage of the counting path.
  • 🔵 src/sentry/integrations/github/integration.py (L394)
    After routing both providers through the shared get_merged_pr_single_issue_template, GitHubPRCommentWorkflow.format_comment_subtitle (and its GitLab twin) is now completely unused (zero call sites, repo-wide). Remove both dead provider-specific helpers; the str | None handling distinction between them is now untestable cruft.
  • 🔵 tests/sentry/integrations/gitlab/tasks/test_pr_comment.py (L393)
    The GitHub suite asserts the debounce-cache-release/error-metric contracts for missing-org/project/repo/integration paths, but the GitLab suite covers only api_error, rate_limited_error, and no_issues. Since the missing-entity branches live in the shared pr_comment_workflow task, mirror at least the release-the-cache assertions in the GitLab suite so shared-handler regressions can't slip through provider-specific coverage.
  • 🔵 src/sentry/integrations/source_code_management/commit_context.py (L143)
    MERGED_PR_SINGLE_ISSUE_TEMPLATE ends with a hard \n and both providers join with "\n", producing \n\n between issues. Intentional per the tests, but redundant and couples the template to the join strategy. Also _truncate_title (line 578) has no direct unit test despite new truncation behavior.
  • 🔵 src/sentry/hybridcloud/tasks/deliver_webhooks.py (L205)
    In the sequential drain_mailbox, deliver_message(record) returns without raising when payload.attempts >= MAX_ATTEMPTS, yet the loop unconditionally runs delivered += 1 — discarded webhooks are tallied as delivered (and 4xx/ApiConflictError drops are also counted as 'ok'). The parallel path records attempts_exceed without incrementing delivered. Use a separate discards/drops counter so delivered reflects true deliveries.
  • 🔵 src/sentry/tasks/auth/check_auth.py (L99)
    check_auth_identities unconditionally enqueues another check_auth, creating a drain-driven check_auth → check_auth_identities → check_auth chain with no delay/backoff; while stale identities remain (provider failures or backlogs) the two tasks ping-pong as fast as scheduling allows — effectively a busy loop. Add a small delay/countdown or rely on the scheduler, and document the intended concurrency.
  • 🔵 src/sentry/workflow_engine/processors/delayed_workflow.py (L652)
    The new no_slow_condition_groups logging branch (lines 654-663) is not exercised by any test, and there is no end-to-end test of process_delayed_workflows covering buffer-fetch → Snuba aggregation → slow-condition evaluation → firing → cleanup, nor of the early-return-on-empty-condition_groups path that leaves the buffer uncleaned. Add a pipeline-state test asserting buffer keys are deleted only on successful processing.
  • 🔵 tests/sentry/workflow_engine/endpoints/test_organization_detector_index.py (L43)
    The detector index endpoint returns results via self.paginate(...), yet the test file has no pagination test (per-page limits, cursor/offset, Link headers, empty next-page). Filters and sorting are covered; pagination is not.
  • 🔵 src/sentry/migrations/0917_convert_org_saved_searches_to_views.py (L42)
    Both 0917 and 0920 no-op placeholder migrations retain hints={"tables": ["sentry_groupsearchview", "sentry_savedsearch"]} even though the RunPython functions are pure no-ops that never touch those tables. Harmless but misleading; consider removing the hints (same applies to 0920 at line 42).
  • 🔵 src/sentry/feedback/usecases/feedback_summaries.py (L52)
    make_input_prompt instructs the model 'AT MOST 55 words' / 'AT MOST two sentences', but parse_response only collapses whitespace — nothing validates or enforces these limits server-side. An over-length summary is cached and returned verbatim to the UI with no backstop. Validate/truncate, or at minimum emit a feedback.summaries.too_long metric.
  • 🔵 src/sentry/feedback/usecases/feedback_summaries.py (L44)
    In generate_summary, the guard is if response: (truthy), but parse_response can normalize a whitespace-only response to "", which is then returned, cached, and served as summary: "" to the UI. Reject/normalize empty results so the endpoint doesn't persist an empty summary.
  • 🔵 static/app/components/feedback/list/useFeedbackSummary.tsx (L40)
    enabled: Boolean(normalizedDateRange) && organization.features.includes(...): normalizeDateTimeParams(selection.datetime) returns an object so Boolean(...) is always true — the date-range guard is a no-op. Remove it or test for a populated range (e.g. normalizedDateRange.start || normalizedDateRange.end || normalizedDateRange.statsPeriod).
  • 🔵 static/app/components/feedback/list/useFeedbackSummary.tsx (L63)
    The final return {summary: data.summary, ...} dereferences data without a guard. useApiQuery can return data === undefined in fall-through states (e.g. disabled + previously-cached data), which would throw inside the hook. Guard with data?.summary ?? null (and data?.numFeedbacksUsed ?? 0 for tooFewFeedbacks).
  • 🔵 tests/sentry/replays/test_project_replay_summarize_breadcrumbs.py (L139)
    Each test inserts a single error event, so the fetch_error_details path with multiple errors — and critically the missing-nodestore-node case — is unverified (such a test would have surfaced the zip bug above). Add a unit test of fetch_error_details (multi events + one missing) and an endpoint test where a referenced error's node is absent.
  • 🔵 src/sentry/replays/endpoints/project_replay_summarize_breadcrumbs.py (L84)
    process_raw_response(snuba_response, fields=request.query_params.getlist("field")) strips columns to the requested fields; a caller passing e.g. ?field=id drops error_ids and silently disables error context even when enable_error_context is true. Pass fields=[] or validate error_ids is always included, and document the interaction.
  • 🔵 src/sentry/replays/endpoints/project_replay_summarize_breadcrumbs.py (L136)
    error_events.sort(...) mutates the list captured by the functools.partial closure and shared across pagination pages. Idempotent today, but sort a copy (sorted(error_events, key=...)) so the function doesn't mutate its input.
  • 🔵 src/sentry/replays/endpoints/project_replay_summarize_breadcrumbs.py (L152)
    gen_request_data uses strict error_events[error_idx]["timestamp"] < event.get("timestamp", 0); same-second errors (common with coarse recording-segment timestamps) are ordered after the equal-timestamp event. Consider <= for more faithful chronological interleaving.
  • 🔵 src/sentry/replays/endpoints/project_replay_summarize_breadcrumbs.py (L53)
    Minor cleanups: the get docstring ('Return a collection of replay recording segments.') is now inaccurate given error fetching; ErrorEvent["category"] is always the literal "error" and never read; and fetch_error_details silently swallows nodestore failures (beyond SDK capture) — consider an error-level log.
  • 🔵 static/app/utils/analytics/replayAnalyticsEvents.tsx (L116)
    The new replay.timeline.zoom-in/zoom-out event schemas are loose Record<string, unknown> and the tracking calls only pass {organization} — no distinguishing payload (scale/duration delta). Capture the resulting timelineScale/maxScale and type the events concretely, consistent with sibling replay events.
  • 🔵 static/app/components/replays/timeAndScrubberGrid.tsx (L35)
    handleZoomOut/handleZoomIn call trackAnalytics unconditionally after computing newScale; though buttons are disabled at boundaries (timelineScale === 1 / === maxScale), tracking fires even if the new value is clamped to the current value (e.g. if maxScale shrinks between renders). Track only when the scale actually changes.
  • 🔵 static/app/views/replays/detail/ai/index.tsx (L124)
    chapterData computes end: period_end * 1000 per chapter but the render loop only consumes {title, start, breadcrumbs} — the end field is dead. Either drop it or use it to bound breadcrumb filtering.
  • 🔵 src/sentry/preprod/analytics.py (L5)
    The event type is defined once on the class and re-specified as a raw string literal at the call site — the two can drift. Prefer analytics.record(PreprodArtifactApiAssembleEvent.type, ...) for a single source of truth.
  • 🔵 src/sentry/preprod/__init__.py (L1)
    from .analytics import * re-exports the event class but also binds the name analytics (from from sentry import analytics inside analytics.py) into the sentry.preprod namespace, pointing at sentry.analytics rather than the sentry.preprod.analytics submodule — a latent source of confusion. Define __all__ in analytics.py or use an explicit import.
  • 🔵 src/sentry/preprod/api/endpoints/organization_preprod_artifact_assemble.py (L102)
    find_missing_chunks(...) runs before get_assemble_status(...), diverging from organization_release_assemble.py which checks cached status first. If an assembly completed (state OK cached) but blobs were pruned, a re-poll returns NOT_FOUND instead of the cached completed state. Confirm intentional, else swap the checks.
  • 🔵 src/sentry/preprod/api/endpoints/organization_preprod_artifact_assemble.py (L48)
    When a chunks item fails ^[0-9a-f]{40}$, e.path[0] is "chunks" so the message reads 'The chunks field is required and must be provided as an array of 40-character hexadecimal strings' — misleading for a single malformed item. Use e.message for item-level errors or distinguish required-field from item-pattern errors.
  • 🔵 src/sentry/preprod/api/endpoints/organization_preprod_artifact_assemble.py (L133)
    update_org_auth_token_last_used(...) is only reached on the happy path after apply_async; NOT_FOUND/poll/validation short-circuits don't refresh the token. Verify the intended semantics — if any successful authenticated request should count, update before the early returns.
  • 🔵 static/app/views/explore/contexts/pageParamsContext/visualizes.tsx (L187)
    determineDefaultChartType uses counts[ct] >= counts[acc] iterating AREA→BAR→LINE, so any tie resolves to LINE (the last element). The constructor only passes a single-element array today so it never triggers, but the latent inconsistency would surprise future multi-yAxis reuse. Add a deliberate tie-break rule.
  • 🔵 static/app/views/explore/contexts/pageParamsContext/visualizes.tsx (L49)
    this.stack = 'all' unconditionally: previously multi-series bar charts were unstacked. Now that each Visualize is single-yAxis this is internally consistent, but multiple bar overlays rendered together will stack on top of one another — a visible rendering change. Confirm the intended UX; if not desired, use stack: undefined for bar overlays.
  • 🔵 static/app/views/explore/contexts/pageParamsContext/visualizes.tsx (L78)
    Visualize.fromJSON(json: BaseVisualize): Visualize[] returns an array despite a singular name; callers must .map() over the result. Correct and necessary for the legacy multi-yAxis migration, but rename (e.g. fromJSONArray/expand) or document the return type to avoid the pre-change single-Visualize footgun.
  • 🔵 static/app/views/explore/hooks/useExploreAggregatesTable.tsx (L23)
    UseExploreAggregatesTableOptions exposes queryExtras but useExploreAggregatesTable only destructures {enabled, limit, query} — a caller-supplied queryExtras is silently discarded (the inner hook does consume it, and the timeseries sibling doesn't accept it). Thread it through or remove it from the interface.
  • 🔵 static/app/views/explore/tables/aggregateColumnEditorModal.tsx (L63)
    groupBys is memoized from the columns prop, not the live tempColumns state, so a group-by added during the current edit session won't be excluded from the other rows' drop-down lists until the modal closes. Derive it from tempColumns.
  • 🔵 static/app/views/explore/tables/aggregateColumnEditorModal.tsx (L66)
    onColumnsChange(tempColumns.map(col => (isVisualize(col) ? col.toJSON() : col)))isVisualize requires a string yAxis, so a raw BaseVisualize ({yAxes:[...]}) flowing into the modal would be forwarded verbatim with no chartType. The guard's dual role (type-narrowing + serialization decision) is fragile; serialize on isBaseVisualize/instanceof Visualize (low severity, worth documenting).
  • 🔵 static/app/views/explore/toolbar/toolbarVisualize.tsx (L47)
    The visible A/B/C chart labels exist solely in the URL-serialized aggregateField ordering and are discarded whenever a Visualize is serialized with toJSON() — labels are never persisted and only rematerialized via the location round-trip. Fine today (matches the spec), but a saved-query/compare feature needing stable chart identity independent of position would hit a trap. Keep it a conscious design decision.
  • 🔵 static/app/views/dashboards/widgets/tableWidget/fixtures/sampleHTTPRequestTableData.ts (L8)
    Each data row includes an id: '' key not present in meta.fields/meta.units; since columnOrder derives from Object.keys(meta.fields), the id is never rendered and is misleading fixture data. Remove it or declare it in meta.
  • 🔵 static/app/views/dashboards/widgets/tableWidget/defaultTableCellRenderers.tsx (L76)
    if (!tableData?.meta) { return dataRow[column.key]; } is dead code — TableWidgetVisualization requires tableData and always passes it — and returning a raw possibly-non-React-node value is a footgun if a future caller omits tableData. Tighten the prop to be required or remove the fallback. (Related: Object.keys(tableData?.meta.fields) optional-chains on a required prop in tableWidgetVisualization.tsx.)
  • 🔵 static/app/views/dashboards/widgets/tableWidget/defaultTableCellRenderers.tsx (L35)
    column.type as ColumnValueType (head) and tableData.meta.units?.[columnKey] as string (body) are unchecked casts across differing types (AttributeValueType | undefined vs ColumnValueType; DataUnit | null vs string). They work only because downstream consumers tolerate the values; prefer narrow type guards (column.type ?? undefined, map DataUnit | null to string | undefined).
  • 🔵 static/app/views/performance/newTraceDetails/traceTabsAndVitals.tsx (L46)
    The ResizeObserver callback calls setContainerWidth(clientWidth) with no guard; ResizeObserver can fire for height/count reasons, causing needless re-renders. Use a functional guard: setContainerWidth(prev => prev === width ? prev : width).
  • 🔵 static/app/components/group/times.tsx (L37)
    The hidden-xs hidden-sm responsive class moved inside the TextOverflow wrapper rather than on the flex item; on small screens the TimeSince is display:none but the wrapper remains as an (empty, ~0-width) item. Semantics are off — move the class to the wrapper or conditionally omit it.
  • 🔵 static/app/views/insights/pages/transactionNameSearchBar.tsx (L174)
    const fullQuery = asRawText ? query : `transaction:"${query}"`; interpolates the user-authored transaction name into the search token without escaping; a name containing " or other search syntax breaks the constructed MutableSearch. Escape/quote the value before embedding.
  • 🔵 static/app/views/insights/pages/transactionNameSearchBar.tsx (L92)
    With nothing highlighted (highlightedItemIndex === -1) and ArrowUp pressed, (highlightedItemIndex + transactionCount - 1) % transactionCount yields count - 2, skipping the last suggestion. ArrowUp from an empty selection should wrap to the final item (count - 1).
  • 🔵 tests/snuba/api/endpoints/test_organization_events_stats.py (L3184)
    assert [{"count": count}] in data[message]["data"][0] only inspects the first interval bucket, so it would silently pass even if the second bucket were not zero-filled (passes only because all logs land in the first hour). Follow the spans sibling pattern ([attrs for _, attrs in data[message]["data"]]) to also verify zero-fill.
  • 🔵 tests/snuba/api/endpoints/test_organization_events_stats.py (L3189)
    assert [{"count": 10}] in other["data"][0] hardcodes the Other bucket count; it only holds because exactly one group falls outside the top 5. Derive it from the data (e.g. sum(... expected_message_counts[5:])) so fixture changes don't silently break it.
  • 🔵 tests/snuba/api/endpoints/test_organization_events_stats.py (L3150)
    The new OrganizationEventsStatsTopNEventsLogs only exercises yAxis="count()" top-events on the ourlogs dataset — no meta assertion, no second-bucket zero-fill check, no other yAxis function, unlike the spans class. Acceptable as a sanity check, but a meta assertion and zero-fill check would bring it in line with equivalent coverage.

✅ Feature-level checklist

Partially covered

  • ⚠️ Replays Self-Serve Bulk Delete System — implement a self-serve bulk delete flow for replays (delete replay recordings/blobs, checkpoint offsets so retries resume, and gate/configure the job).
    The delete path is implemented and directionally correct (idempotent deletes via _delete_if_exists silencing 404s, monotonic offset checkpointing, safe re-processing). However it is not fully compliant: the thread-pool blob deletion silently swallows worker exceptions leaving orphaned blobs with no retry (src/sentry/replays/usecases/delete.py:75), no replay bulk-delete option is registered in defaults.py for rollout gating (src/sentry/options/defaults.py:2627), and the max_segment_id=None fix is never exercised by tests (tests/sentry/replays/tasks/test_delete_replays_bulk.py:97). The per-row sequential thread pools also risk deadline overruns (delete.py:74), and has_more has an off-by-one (delete.py:170).