Skip to content

Commit 1e2f075

Browse files
authored
Merge branch 'master' into set-referrer-org-menu
2 parents 581aa45 + b625680 commit 1e2f075

File tree

260 files changed

+4931
-2728
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

260 files changed

+4931
-2728
lines changed

migrations_lockfile.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ prevent: 0002_alter_integration_id_not_null
2929

3030
releases: 0004_cleanup_failed_safe_deletes
3131

32-
replays: 0006_add_bulk_delete_job
32+
replays: 0007_organizationmember_replay_access
3333

3434
sentry: 1013_add_repositorysettings_table
3535

src/sentry/api/endpoints/organization_trace_item_stats.py

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import logging
2+
from collections import defaultdict
3+
from concurrent.futures import ThreadPoolExecutor, as_completed
24

35
from rest_framework import serializers
46
from rest_framework.request import Request
@@ -13,7 +15,7 @@
1315
from sentry.api.bases import NoProjects, OrganizationEventsEndpointBase
1416
from sentry.api.endpoints.organization_trace_item_attributes import adjust_start_end_window
1517
from sentry.api.event_search import translate_escape_sequences
16-
from sentry.api.serializers import serialize
18+
from sentry.api.serializers.base import serialize
1719
from sentry.api.utils import handle_query_errors
1820
from sentry.models.organization import Organization
1921
from sentry.search.eap.constants import SUPPORTED_STATS_TYPES
@@ -73,6 +75,7 @@ class OrganizationTraceItemsStatsSerializer(serializers.Serializer):
7375
limit = serializers.IntegerField(
7476
required=False,
7577
)
78+
spansLimit = serializers.IntegerField(required=False, default=1000, max_value=1000)
7679

7780

7881
@region_silo_endpoint
@@ -98,9 +101,6 @@ def get(self, request: Request, organization: Organization) -> Response:
98101
params=snuba_params, config=resolver_config, definitions=SPAN_DEFINITIONS
99102
)
100103

101-
query_string = serialized.get("query")
102-
query_filter, _, _ = resolver.resolve_query(query_string)
103-
104104
substring_match = serialized.get("substringMatch", "")
105105
value_substring_match = translate_escape_sequences(substring_match)
106106

@@ -127,12 +127,12 @@ def get_table_results():
127127
params=snuba_params,
128128
config=SearchResolverConfig(),
129129
offset=0,
130-
limit=1000,
130+
limit=serialized.get("spansLimit", 1000),
131131
sampling_mode=snuba_params.sampling_mode,
132132
query_string=serialized.get("query", ""),
133-
orderby=["span_id"],
133+
orderby=["-timestamp"],
134134
referrer=Referrer.API_SPANS_FREQUENCY_STATS_RPC.value,
135-
selected_columns=["span_id"],
135+
selected_columns=["span_id", "timestamp"],
136136
)
137137

138138
def run_stats_query_with_span_ids(span_id_filter):
@@ -204,9 +204,29 @@ def data_fn(offset: int, limit: int):
204204
AttributeKey(name=requested_key, type=AttributeKey.TYPE_STRING)
205205
)
206206

207-
stats_results = run_stats_query_with_error_handling(request_attrs_list)
207+
chunked_attributes: defaultdict[int, list[AttributeKey]] = defaultdict(list)
208+
for i, attr in enumerate(request_attrs_list):
209+
chunked_attributes[i % MAX_THREADS].append(
210+
AttributeKey(name=attr.name, type=AttributeKey.TYPE_STRING)
211+
)
208212

209-
return {"data": stats_results}, len(request_attrs_list)
213+
stats_results: dict[str, dict[str, dict]] = defaultdict(lambda: {"data": {}})
214+
with ThreadPoolExecutor(
215+
thread_name_prefix=__name__,
216+
max_workers=MAX_THREADS,
217+
) as query_thread_pool:
218+
futures = [
219+
query_thread_pool.submit(run_stats_query_with_error_handling, attributes)
220+
for attributes in chunked_attributes.values()
221+
]
222+
223+
for future in as_completed(futures):
224+
result = future.result()
225+
for stats in result:
226+
for stats_type, data in stats.items():
227+
stats_results[stats_type]["data"].update(data["data"])
228+
229+
return {"data": [{k: v} for k, v in stats_results.items()]}, len(request_attrs_list)
210230

211231
return self.paginate(
212232
request=request,

src/sentry/api/serializers/models/organization.py

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@
8181
from sentry.models.team import Team, TeamStatus
8282
from sentry.organizations.absolute_url import generate_organization_url
8383
from sentry.organizations.services.organization import RpcOrganizationSummary
84+
from sentry.replays.models import OrganizationMemberReplayAccess
8485
from sentry.users.models.user import User
8586
from sentry.users.services.user.model import RpcUser
8687
from sentry.users.services.user.service import user_service
@@ -563,13 +564,51 @@ class DetailedOrganizationSerializerResponse(_DetailedOrganizationSerializerResp
563564
autoEnableCodeReview: bool
564565
autoOpenPrs: bool
565566
defaultCodeReviewTriggers: list[str]
567+
hasGranularReplayPermissions: bool
568+
replayAccessMembers: list[int]
566569

567570

568571
class DetailedOrganizationSerializer(OrganizationSerializer):
569572
def get_attrs(
570573
self, item_list: Sequence[Organization], user: User | RpcUser | AnonymousUser, **kwargs: Any
571574
) -> MutableMapping[Organization, MutableMapping[str, Any]]:
572-
return super().get_attrs(item_list, user)
575+
attrs = super().get_attrs(item_list, user)
576+
577+
replay_permissions = {}
578+
has_feature = features.batch_has_for_organizations(
579+
"organizations:granular-replay-permissions", item_list
580+
)
581+
if has_feature and any(has_feature.values()):
582+
replay_permissions = {
583+
opt.organization_id: opt.value
584+
for opt in OrganizationOption.objects.filter(
585+
organization__in=item_list, key="sentry:granular-replay-permissions"
586+
)
587+
}
588+
589+
# Only process replay access data if replay_permissions is enabled for at least one org
590+
enabled_org_ids = [org_id for org_id, enabled in replay_permissions.items() if enabled]
591+
replay_access_by_org: dict[int, list[int]] = {}
592+
if enabled_org_ids:
593+
for org_id, user_id in OrganizationMemberReplayAccess.objects.filter(
594+
organizationmember__organization__in=enabled_org_ids
595+
).values_list("organizationmember__organization_id", "organizationmember__user_id"):
596+
if user_id is not None:
597+
replay_access_by_org.setdefault(org_id, []).append(user_id)
598+
599+
for item in item_list:
600+
attrs[item]["replay_permissions_enabled"] = replay_permissions.get(item.id, False)
601+
attrs[item]["replay_access_members"] = (
602+
replay_access_by_org.get(item.id, [])
603+
if replay_permissions.get(item.id, False)
604+
else []
605+
)
606+
else:
607+
for item in item_list:
608+
attrs[item]["replay_permissions_enabled"] = False
609+
attrs[item]["replay_access_members"] = []
610+
611+
return attrs
573612

574613
def serialize( # type: ignore[override]
575614
self,
@@ -745,8 +784,14 @@ def serialize( # type: ignore[override]
745784
team__organization=obj
746785
).count(),
747786
"isDynamicallySampled": is_dynamically_sampled,
787+
"hasGranularReplayPermissions": False,
788+
"replayAccessMembers": [],
748789
}
749790

791+
if features.has("organizations:granular-replay-permissions", obj):
792+
context["hasGranularReplayPermissions"] = bool(attrs.get("replay_permissions_enabled"))
793+
context["replayAccessMembers"] = attrs.get("replay_access_members", [])
794+
750795
if has_custom_dynamic_sampling(obj, actor=user):
751796
context["targetSampleRate"] = float(
752797
obj.get_option("sentry:target_sample_rate", TARGET_SAMPLE_RATE_DEFAULT)
@@ -796,6 +841,8 @@ def serialize( # type: ignore[override]
796841
"streamlineOnly",
797842
"ingestThroughTrustedRelaysOnly",
798843
"enabledConsolePlatforms",
844+
"hasGranularReplayPermissions",
845+
"replayAccessMembers",
799846
]
800847
)
801848
class DetailedOrganizationSerializerWithProjectsAndTeamsResponse(

src/sentry/api/urls.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -535,6 +535,9 @@
535535
from sentry.seer.endpoints.organization_seer_rpc import OrganizationSeerRpcEndpoint
536536
from sentry.seer.endpoints.organization_seer_setup_check import OrganizationSeerSetupCheck
537537
from sentry.seer.endpoints.organization_trace_summary import OrganizationTraceSummaryEndpoint
538+
from sentry.seer.endpoints.project_autofix_automation_settings import (
539+
ProjectAutofixAutomationSettingsEndpoint,
540+
)
538541
from sentry.seer.endpoints.project_seer_preferences import ProjectSeerPreferencesEndpoint
539542
from sentry.seer.endpoints.seer_rpc import SeerRpcServiceEndpoint
540543
from sentry.seer.endpoints.trace_explorer_ai_query import TraceExplorerAIQuery
@@ -2675,6 +2678,11 @@ def create_group_urls(name_prefix: str) -> list[URLPattern | URLResolver]:
26752678
ProjectAlertRuleIndexEndpoint.as_view(),
26762679
name="sentry-api-0-project-alert-rules",
26772680
),
2681+
re_path(
2682+
r"^(?P<organization_id_or_slug>[^/]+)/(?P<project_id_or_slug>[^/]+)/autofix/automation-settings/$",
2683+
ProjectAutofixAutomationSettingsEndpoint.as_view(),
2684+
name="sentry-api-0-project-autofix-automation-settings",
2685+
),
26782686
re_path(
26792687
r"^(?P<organization_id_or_slug>[^/]+)/(?P<project_id_or_slug>[^/]+)/alert-rule-task/(?P<task_uuid>[^/]+)/$",
26802688
ProjectAlertRuleTaskDetailsEndpoint.as_view(),

src/sentry/backup/comparators.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -982,6 +982,9 @@ def get_default_comparators() -> dict[str, list[JSONScrubbingComparator]]:
982982
DateUpdatedComparator("date_updated", "date_added")
983983
],
984984
"monitors.monitor": [UUID4Comparator("guid")],
985+
"replays.organizationmemberreplayaccess": [
986+
DateUpdatedComparator("date_updated", "date_added")
987+
],
985988
},
986989
)
987990

src/sentry/core/endpoints/organization_details.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from django.utils import timezone as django_timezone
1212
from drf_spectacular.utils import OpenApiResponse, extend_schema, extend_schema_serializer
1313
from rest_framework import serializers, status
14+
from rest_framework.exceptions import NotFound, PermissionDenied
1415
from sentry_sdk import capture_exception
1516

1617
from bitfield.types import BitHandler
@@ -94,6 +95,7 @@
9495
from sentry.models.options.organization_option import OrganizationOption
9596
from sentry.models.options.project_option import ProjectOption
9697
from sentry.models.organization import Organization, OrganizationStatus
98+
from sentry.models.organizationmember import OrganizationMember
9799
from sentry.models.project import Project
98100
from sentry.organizations.services.organization import organization_service
99101
from sentry.organizations.services.organization.model import (
@@ -102,6 +104,7 @@
102104
RpcOrganizationDeleteState,
103105
)
104106
from sentry.relay.datascrubbing import validate_pii_config_update, validate_pii_selectors
107+
from sentry.replays.models import OrganizationMemberReplayAccess
105108
from sentry.seer.autofix.constants import AutofixAutomationTuningSettings
106109
from sentry.services.organization.provisioning import (
107110
OrganizationSlugCollisionException,
@@ -369,6 +372,13 @@ class OrganizationSerializer(BaseOrganizationSerializer):
369372
ingestThroughTrustedRelaysOnly = serializers.ChoiceField(
370373
choices=[("enabled", "enabled"), ("disabled", "disabled")], required=False
371374
)
375+
hasGranularReplayPermissions = serializers.BooleanField(required=False)
376+
replayAccessMembers = serializers.ListField(
377+
child=serializers.IntegerField(),
378+
required=False,
379+
allow_null=True,
380+
help_text="List of user IDs that have access to replay data. Only modifiable by owners and managers.",
381+
)
372382

373383
def _has_sso_enabled(self):
374384
org = self.context["organization"]
@@ -475,6 +485,26 @@ def validate_samplingMode(self, value):
475485

476486
return value
477487

488+
def validate_hasGranularReplayPermissions(self, value):
489+
self._validate_granular_replay_permissions()
490+
return value
491+
492+
def validate_replayAccessMembers(self, value):
493+
self._validate_granular_replay_permissions()
494+
return value
495+
496+
def _validate_granular_replay_permissions(self):
497+
organization = self.context["organization"]
498+
request = self.context["request"]
499+
500+
if not features.has("organizations:granular-replay-permissions", organization):
501+
raise NotFound("This feature is not enabled for your organization.")
502+
503+
if not request.access.has_scope("org:admin"):
504+
raise PermissionDenied(
505+
"You do not have permission to modify granular replay permissions."
506+
)
507+
478508
def validate(self, attrs):
479509
attrs = super().validate(attrs)
480510
if attrs.get("avatarType") == "upload":
@@ -589,6 +619,74 @@ def save(self, **kwargs):
589619
if trusted_relay_info is not None:
590620
self.save_trusted_relays(trusted_relay_info, changed_data, org)
591621

622+
if "hasGranularReplayPermissions" in data:
623+
option_key = "sentry:granular-replay-permissions"
624+
new_value = data["hasGranularReplayPermissions"]
625+
option_inst, created = OrganizationOption.objects.get_or_create(
626+
organization=org, key=option_key, defaults={"value": new_value}
627+
)
628+
if not created and option_inst.value != new_value:
629+
old_val = option_inst.value
630+
option_inst.value = new_value
631+
option_inst.save()
632+
changed_data["hasGranularReplayPermissions"] = f"from {old_val} to {new_value}"
633+
elif created:
634+
changed_data["hasGranularReplayPermissions"] = f"to {new_value}"
635+
636+
if "replayAccessMembers" in data:
637+
user_ids = data["replayAccessMembers"]
638+
if user_ids is None:
639+
user_ids = []
640+
641+
current_user_ids = set(
642+
OrganizationMemberReplayAccess.objects.filter(
643+
organizationmember__organization=org
644+
).values_list("organizationmember__user_id", flat=True)
645+
)
646+
new_user_ids = set(user_ids)
647+
648+
to_add = new_user_ids - current_user_ids
649+
to_remove = current_user_ids - new_user_ids
650+
651+
if to_add:
652+
user_to_member = dict(
653+
OrganizationMember.objects.filter(
654+
organization=org, user_id__in=to_add
655+
).values_list("user_id", "id")
656+
)
657+
invalid_user_ids = to_add - set(user_to_member.keys())
658+
if invalid_user_ids:
659+
raise serializers.ValidationError(
660+
{
661+
"replayAccessMembers": f"Invalid user IDs (not members of this organization): {sorted(invalid_user_ids)}"
662+
}
663+
)
664+
665+
OrganizationMemberReplayAccess.objects.bulk_create(
666+
[
667+
OrganizationMemberReplayAccess(
668+
organizationmember_id=user_to_member[user_id]
669+
)
670+
for user_id in to_add
671+
],
672+
ignore_conflicts=True,
673+
)
674+
675+
if to_remove:
676+
OrganizationMemberReplayAccess.objects.filter(
677+
organizationmember__organization=org, organizationmember__user_id__in=to_remove
678+
).delete()
679+
680+
if to_add or to_remove:
681+
changes = []
682+
if to_add:
683+
changes.append(f"added {len(to_add)} user(s)")
684+
if to_remove:
685+
changes.append(f"removed {len(to_remove)} user(s)")
686+
changed_data["replayAccessMembers"] = (
687+
f"{' and '.join(changes)} (total: {len(new_user_ids)} user(s) with access)"
688+
)
689+
592690
if "openMembership" in data:
593691
org.flags.allow_joinleave = data["openMembership"]
594692
if "allowSharedIssues" in data:
@@ -809,6 +907,16 @@ class OrganizationDetailsPutSerializer(serializers.Serializer):
809907
help_text="The role required to download debug information files, ProGuard mappings and source maps.",
810908
required=False,
811909
)
910+
hasGranularReplayPermissions = serializers.BooleanField(
911+
help_text="Specify `true` to enable granular replay permissions, allowing per-member access control for replay data.",
912+
required=False,
913+
)
914+
replayAccessMembers = serializers.ListField(
915+
child=serializers.IntegerField(),
916+
help_text="A list of user IDs who have permission to access replay data. Requires the hasGranularReplayPermissions flag to be true to be enforced.",
917+
required=False,
918+
allow_null=True,
919+
)
812920

813921
# avatar
814922
avatarType = serializers.ChoiceField(

0 commit comments

Comments
 (0)