Replays Self-Serve Bulk Delete System
ai-code-review-evaluation/sentry-greptile#5Golden 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_auth ↔ check_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
FriendlyReviewer's full review
As posted on the PR — the complete output from FriendlyReviewer.
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
ThreadPoolExecutorblob-deletion path swallows worker exceptions, leaving orphaned blobs with no retry; no replay bulk-delete option is registered for rollout gating; and themax_segment_id=Nonefix is never exercised by tests. - Dashboard flag: the
use-table-widget-visualizationflag renders a hardcoded empty table, discarding real query results the moment the flag is enabled. - Webhooks:
drain_mailbox_parallelgets 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_syncedwas 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_detailspairs event IDs to nodestore data viazip(error_ids, events.values()), which is unreliable givenget_multidedup/caching behavior. - grouptype:
should_create_groupdeletes 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 andThreadPoolExecutor.__exit__only waits; combined with_delete_if_existscatching onlyNotFound, transient storage/network errors are silently swallowed. A failed blob delete is dropped, the Kafka archive event is published, and the task marks the jobcompleted— 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_parallelgetsprocessing_deadline_duration=120, but both drain paths set their internal loop deadline toBATCH_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_identitiesnow hasprocessing_deadline_duration=60and processes its wholechunk_size=100batch synchronously with outbound SSO refresh network I/O per item. If the worker is interrupted mid-batch,check_auth(line 52) already advancedlast_synced=nowfor 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 advancelast_syncedfor identities actually verified. - 🔴 src/sentry/workflow_engine/processors/delayed_workflow.py (L652)
process_delayed_workflowsreads the entire Redis buffer, fires actions, and only afterwards deletes keys — there is no atomic claim/lease on the buffer. The newRetry(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_actionsnarrows 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_detailspairs ids to data viazip(error_ids, events.values()), butget_multidedups ids (dict.fromkeys) and appends cache-only ids at the end, so dict order does not reliably match input order. AnErrorEventcan 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)
Whenuse-table-widget-visualizationis enabled,TableWidgetVisualizationis rendered withcolumns={[]}and an emptytableDataeven though real results are in scope (result.data,result.meta, derivedfields/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_rowsspins up a freshThreadPoolExecutor(max_workers=100)per row sequentially (up tolimitexecutors serially), so only segments within a single replay are deleted concurrently. For largemax_segment_idvalues this can blow pastprocessing_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 (searchedreplay-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 standardregister(...)pattern is missing. (The only defaults.py change — removinggrouping.experiments.parameterization.traceparent— is verified safe/unreferenced.) - 🟡 src/sentry/projectoptions/defaults.py (L205)
sentry:autofix_automation_tuningdefault change ("low"→"off") uses a plaindefault=with noepoch_defaults, so perWellKnownProjectOption.get_defaultit 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 withepoch_defaults(bumpLATEST_EPOCH). The coordinated change to constants.py/serializers/_is_issue_fixableis otherwise coherent. - 🟡 tests/sentry/api/endpoints/test_browser_reporting_collector.py (L68)
Test-coverage regression for the collector endpoint: (a) the oldtest_logs_request_data_if_option_enabled(patchinglogger.infoand assertingbrowser_report_received) was deleted — there is now no assertion that the option-enabled endpoint logs, andtest_404s_by_defaultdoes not assert that no logging/metrics occur when disabled; (b) no positive test posts withapplication/reports+json, so theBrowserReportsJSONParserpath 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 removingParameterizationRegexExperiment:_handle_regex_match(lines 338-345) is defined but never called (the only loop body callsexperiment.run(...)), andParameterizationRegex.compiled_pattern(lines 41-48) is now unused (only consumer was the removed experiment). Remove both and their now-danglingre/re.Patternusage — keeping them misleadingly suggests regex experiments are still supported. - 🟡 src/sentry/grouping/parameterization.py (L158)
Thedurationpattern\b(\d+ms) | (\d+(\.\d+)?s)\bis compiled with(?x)which ignores the unescaped whitespace, yielding\b(\d+ms)|(\d+(\.\d+)?s)\b— the leading\bapplies only to themsbranch and the trailing\bonly to thesbranch."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_deliveryloops over up toBATCH_SIZE=1000records, each doing a replica read plus a primaryUPDATEof up toMAX_MAILBOX_DRAIN=300rows. Withprocessing_deadline_duration=30the tick can be killed under load; alsoscheduled_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)
Theexcept Exceptionbranch setsis_valid = Falsewith the comment 'to ensure security we count any kind of error as an invalidation event', but the flag write only runs whensso:linked != is_linked; sinceis_linked=Truehere, the common already-linked case skips the write andsso:invalidis never set. The security rationale in the comment is not honored. Drive the flag update offsso:invalid/is_validtoo. - 🟡 src/sentry/workflow_engine/endpoints/validators/base/detector.py (L88)
update()is not atomic and audits after commit:instance.save()(line 88) commits first, thencreate_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 sametransaction.atomic(router.db_for_write(Detector))block used bycreate(). - 🟡 src/sentry/workflow_engine/endpoints/validators/base/detector.py (L90)
Owner changes are persisted but the audit payload isdata=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. Extendget_audit_log_data()(and/or the audit event) to includeowner_user_id/owner_team_id. - 🟡 src/sentry/workflow_engine/processors/delayed_workflow.py (L672)
In bothget_condition_query_groups(line 672) andget_groups_to_fire(line 419),workflow_env = workflows_to_envs[workflow_id] if workflow_id else Nonecan raiseKeyErrorwhen a workflow referenced by a buffered event was deleted (sincefetch_workflows_envsonly returns existing workflows), aborting the whole batch. Useworkflows_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 mockedrequests.postasserting the URL (SEER_AUTOFIX_URL/v1/automation/summarize/replay/breadcrumbs), thecontent-typeheader, and that theRpcsignatureis 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 fullerror_eventslist 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 theisPendingbranch and fires wheneverprojectis undefined — butuseProjectFromIdreturnsundefinedboth when the project doesn't exist and while the asyncProjectsStoreis 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 addednoise_config):should_create_groupincrementskey = f"grouphash:{grouphash}:{project.id}"but on threshold-cross deletesclient.delete(grouphash)— the raw fingerprint string, not the incremented key — so the noise-reduction counter is never reset. Fix toclient.delete(key). The newly-addedNoiseConfig(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 inpost(), before the feature-flag gate (404) and schema validation (400), so every rejected/not-found request also emits an analytics event — any authenticated user withProjectReleasePermissioncan 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'stypeprop, but both production callers settype: 'string'; numeric attributes (tag kind MEASUREMENT) reach this function with the string-typed hook and fall through to issue a request withattributeType: 'string'— the opposite of the intended optimization. The spec only passes because it constructs the hook withtype: '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)
handleGenerateClickonly callssetShowTokenDetails(true); on the truncated viewshowTokenDetailsis already true, so 'Regenerate' (line 92) is a no-op —showFullTokenstays false and the full token can never be shown again after 'Done' without a reload. ResetsetShowFullToken(true)(and ideally generate a distinct token value rather than reusingFULL_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-tokenCodeSnippet, 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 earlyreturn 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 ofsearchQuery. - 🟡 static/app/views/performance/newTraceDetails/traceTabsAndVitals.tsx (L93)
The Flex refactor droppedalign-items: centerandgap: ${space(1)}from the tabs/vitals container. TheFlexcomponent defaults toalign-items: stretch, soTabsandTraceContextVitalsnow stretch vertically instead of being centered and spacing is no longer guaranteed. Addalign="center"andgap={space(1)}to preserve the layout. - 🟡 static/app/views/performance/newTraceDetails/traceWaterfall.tsx (L712)
The Flex refactor droppedheight: 100%(and changedflex-basis: auto→flex-basis: 0%) on the waterfall wrapper.flex: 1should still grow in the parent flex column, but theflex-basischange is a subtle sizing behavior change for theTraceGridinside. Visually verify the waterfall (andissuesTraceWaterfall.tsxvariant) still fills the available height; addheight: '100%'if there's any scroll/fill regression. - 🟡 static/app/components/events/eventAttachments.tsx (L143)
The Flex refactor droppedoverflow: hiddenfrom the attachment name-cell wrapper. OldFlexCentergrid cell carriedoverflow: hidden, letting the1frfirst column shrink soName'snowrap+text-overflow: ellipsistruncates. The new<Flex align="center">has defaultoverflow: 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 theFlexItemimplementation only setsflex-grow: 1while keepingflex-basis: auto. The Description/FeatureList column's base size is now derived from content rather than remaining space, which can overflow or shift theMetadatacolumn for wide content. Use<Flex.Item flex={1}>/basis={0}to preserveflex-basis: 0%, or confirmautois 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 inuseCallback/useMemo). Each keystroke callssetSearchString(re-render) then the debounced fn, so the debounce timer resets every keystroke and withleading: truefires 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 andsetSearchResultsrenders stale suggestions (race condition). Alsocatch (_) { throw new Error(...) }re-throws inside the async debounced fn invoked without.catch()inhandleSearchChange, 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) == limitreportsTruewhen 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 readsegments. - 🔵 tests/sentry/replays/tasks/test_delete_replays_bulk.py (L97)
test_run_bulk_replay_delete_job_completionpassesmax_segment_id: Nonebut mocksdelete_matched_rows, so the newNoneguard in_make_recording_filenamesis never invoked — the core fix has no direct test coverage. Add a direct unit test ofdelete_matched_rows/_make_recording_filenameswith bothNoneand an integer value. - 🔵 devservices/config.yml (L26)
The newsnuba-metricsdependency usesmode: containerized-metrics-dev, an odd naming divergence from the existingcontainerized/containerized-profilesmodes. Confirm against the snuba repo that this is a valid mode the snuba devservices tooling accepts, otherwise thetracingmode will fail to bring up the metrics stack. - 🔵 src/sentry/issues/endpoints/browser_reporting_collector.py (L55)
validate_timestamp/validate_agecheck the other field via truthiness (if self.initial_data.get("age")), so a0value 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_dataand add a0-valued edge-case test. - 🔵 src/sentry/grouping/parameterization.py (L282)
matches_counter— the only signal consumed bynormalize_message_for_groupingfor thegrouping.value_trimmed_from_messagemetrics andget_successful_experimentsanalytics — has no test assertions for the regex path (correct keys/counts) nor for the experiment-gating negative case. The deletion oftest_parameterize_regex_experiment*further reduces coverage of the counting path. - 🔵 src/sentry/integrations/github/integration.py (L394)
After routing both providers through the sharedget_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; thestr | Nonehandling 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 onlyapi_error,rate_limited_error, andno_issues. Since the missing-entity branches live in the sharedpr_comment_workflowtask, 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_TEMPLATEends with a hard\nand both providers join with"\n", producing\n\nbetween 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 sequentialdrain_mailbox,deliver_message(record)returns without raising whenpayload.attempts >= MAX_ATTEMPTS, yet the loop unconditionally runsdelivered += 1— discarded webhooks are tallied as delivered (and 4xx/ApiConflictErrordrops are also counted as 'ok'). The parallel path recordsattempts_exceedwithout incrementingdelivered. Use a separate discards/drops counter sodeliveredreflects true deliveries. - 🔵 src/sentry/tasks/auth/check_auth.py (L99)
check_auth_identitiesunconditionally enqueues anothercheck_auth, creating a drain-drivencheck_auth → check_auth_identities → check_authchain 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 newno_slow_condition_groupslogging branch (lines 654-663) is not exercised by any test, and there is no end-to-end test ofprocess_delayed_workflowscovering buffer-fetch → Snuba aggregation → slow-condition evaluation → firing → cleanup, nor of the early-return-on-empty-condition_groupspath 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 viaself.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 retainhints={"tables": ["sentry_groupsearchview", "sentry_savedsearch"]}even though theRunPythonfunctions 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_promptinstructs the model 'AT MOST 55 words' / 'AT MOST two sentences', butparse_responseonly 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 afeedback.summaries.too_longmetric. - 🔵 src/sentry/feedback/usecases/feedback_summaries.py (L44)
Ingenerate_summary, the guard isif response:(truthy), butparse_responsecan normalize a whitespace-only response to"", which is then returned, cached, and served assummary: ""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 soBoolean(...)is alwaystrue— 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 finalreturn {summary: data.summary, ...}dereferencesdatawithout a guard.useApiQuerycan returndata === undefinedin fall-through states (e.g. disabled + previously-cached data), which would throw inside the hook. Guard withdata?.summary ?? null(anddata?.numFeedbacksUsed ?? 0fortooFewFeedbacks). - 🔵 tests/sentry/replays/test_project_replay_summarize_breadcrumbs.py (L139)
Each test inserts a single error event, so thefetch_error_detailspath 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 offetch_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=iddropserror_idsand silently disables error context even whenenable_error_contextis true. Passfields=[]or validateerror_idsis always included, and document the interaction. - 🔵 src/sentry/replays/endpoints/project_replay_summarize_breadcrumbs.py (L136)
error_events.sort(...)mutates the list captured by thefunctools.partialclosure 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_datauses stricterror_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: thegetdocstring ('Return a collection of replay recording segments.') is now inaccurate given error fetching;ErrorEvent["category"]is always the literal"error"and never read; andfetch_error_detailssilently swallows nodestore failures (beyond SDK capture) — consider anerror-level log. - 🔵 static/app/utils/analytics/replayAnalyticsEvents.tsx (L116)
The newreplay.timeline.zoom-in/zoom-outevent schemas are looseRecord<string, unknown>and the tracking calls only pass{organization}— no distinguishing payload (scale/duration delta). Capture the resultingtimelineScale/maxScaleand type the events concretely, consistent with sibling replay events. - 🔵 static/app/components/replays/timeAndScrubberGrid.tsx (L35)
handleZoomOut/handleZoomIncalltrackAnalyticsunconditionally after computingnewScale; though buttons are disabled at boundaries (timelineScale === 1/=== maxScale), tracking fires even if the new value is clamped to the current value (e.g. ifmaxScaleshrinks between renders). Track only when the scale actually changes. - 🔵 static/app/views/replays/detail/ai/index.tsx (L124)
chapterDatacomputesend: period_end * 1000per chapter but the render loop only consumes{title, start, breadcrumbs}— theendfield is dead. Either drop it or use it to bound breadcrumb filtering. - 🔵 src/sentry/preprod/analytics.py (L5)
The eventtypeis defined once on the class and re-specified as a raw string literal at the call site — the two can drift. Preferanalytics.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 nameanalytics(fromfrom sentry import analyticsinside analytics.py) into thesentry.preprodnamespace, pointing atsentry.analyticsrather than thesentry.preprod.analyticssubmodule — 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 beforeget_assemble_status(...), diverging fromorganization_release_assemble.pywhich 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 achunksitem 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. Usee.messagefor 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 afterapply_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)
determineDefaultChartTypeusescounts[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 eachVisualizeis 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, usestack: undefinedfor 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-Visualizefootgun. - 🔵 static/app/views/explore/hooks/useExploreAggregatesTable.tsx (L23)
UseExploreAggregatesTableOptionsexposesqueryExtrasbutuseExploreAggregatesTableonly destructures{enabled, limit, query}— a caller-suppliedqueryExtrasis 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)
groupBysis memoized from thecolumnsprop, not the livetempColumnsstate, 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 fromtempColumns. - 🔵 static/app/views/explore/tables/aggregateColumnEditorModal.tsx (L66)
onColumnsChange(tempColumns.map(col => (isVisualize(col) ? col.toJSON() : col)))—isVisualizerequires a stringyAxis, so a rawBaseVisualize({yAxes:[...]}) flowing into the modal would be forwarded verbatim with nochartType. The guard's dual role (type-narrowing + serialization decision) is fragile; serialize onisBaseVisualize/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-serializedaggregateFieldordering and are discarded whenever aVisualizeis serialized withtoJSON()— 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 anid: ''key not present inmeta.fields/meta.units; sincecolumnOrderderives fromObject.keys(meta.fields), theidis 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 —TableWidgetVisualizationrequirestableDataand always passes it — and returning a raw possibly-non-React-node value is a footgun if a future caller omitstableData. Tighten the prop to be required or remove the fallback. (Related:Object.keys(tableData?.meta.fields)optional-chains on a required prop intableWidgetVisualization.tsx.) - 🔵 static/app/views/dashboards/widgets/tableWidget/defaultTableCellRenderers.tsx (L35)
column.type as ColumnValueType(head) andtableData.meta.units?.[columnKey] as string(body) are unchecked casts across differing types (AttributeValueType | undefinedvsColumnValueType;DataUnit | nullvsstring). They work only because downstream consumers tolerate the values; prefer narrow type guards (column.type ?? undefined, mapDataUnit | nulltostring | undefined). - 🔵 static/app/views/performance/newTraceDetails/traceTabsAndVitals.tsx (L46)
TheResizeObservercallback callssetContainerWidth(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)
Thehidden-xs hidden-smresponsive class moved inside theTextOverflowwrapper rather than on the flex item; on small screens theTimeSinceisdisplay:nonebut 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 constructedMutableSearch. 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) % transactionCountyieldscount - 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 theOtherbucket 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 newOrganizationEventsStatsTopNEventsLogsonly exercisesyAxis="count()"top-events on theourlogsdataset — nometaassertion, no second-bucket zero-fill check, no other yAxis function, unlike the spans class. Acceptable as a sanity check, but ametaassertion 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_existssilencing 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 themax_segment_id=Nonefix 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), andhas_morehas an off-by-one (delete.py:170).
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 thezip(error_ids, events.values())pairing bug, found at the exact location (L118) with the exact mechanism (get_multidedup 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": ...}). Ondetector.py, FR covered the non-atomicupdate()(L88) and the missing owner-change audit (L90) — but not the wrong key namedetector_typevs the serializer fieldtype. 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 apartialflag (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_parallel120s deadline shorter than its own 180s internal loop,check_authadvancinglast_syncedfor an interrupted 60s batch, the TOCTOU on thedelayed_workflowsRedis buffer, and theuse-table-widget-visualizationflag 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.