Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/sentry/seer/endpoints/seer_rpc.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@
rpc_get_transactions_for_project,
)
from sentry.seer.explorer.tools import (
execute_table_query,
execute_timeseries_query,
execute_trace_query_chart,
execute_trace_query_table,
get_issue_details,
Expand Down Expand Up @@ -1196,6 +1198,8 @@ def check_repository_integrations_status(*, repository_integrations: list[dict[s
"get_issue_details": get_issue_details,
"execute_trace_query_chart": execute_trace_query_chart,
"execute_trace_query_table": execute_trace_query_table,
"execute_table_query": execute_table_query,
"execute_timeseries_query": execute_timeseries_query,
"get_repository_definition": get_repository_definition,
"call_custom_tool": call_custom_tool,
#
Expand Down
194 changes: 164 additions & 30 deletions src/sentry/seer/explorer/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@

from sentry import eventstore, features
from sentry.api import client
from sentry.api.endpoints.organization_events_timeseries import TOP_EVENTS_DATASETS
from sentry.api.serializers.base import serialize
from sentry.api.serializers.models.event import EventSerializer, IssueEventSerializerResponse
from sentry.api.serializers.models.group import GroupSerializer
from sentry.api.utils import default_start_end_dates
from sentry.constants import ObjectStatus
from sentry.constants import ALL_ACCESS_PROJECT_ID, ObjectStatus
from sentry.models.apikey import ApiKey
from sentry.models.group import Group
from sentry.models.organization import Organization
Expand All @@ -18,14 +19,15 @@
from sentry.replays.post_process import process_raw_response
from sentry.replays.query import query_replay_id_by_prefix, query_replay_instance
from sentry.search.eap.types import SearchResolverConfig
from sentry.search.events.types import SnubaParams
from sentry.search.events.types import SAMPLING_MODES, SnubaParams
from sentry.seer.autofix.autofix import get_all_tags_overview
from sentry.seer.constants import SEER_SUPPORTED_SCM_PROVIDERS
from sentry.seer.sentry_data_models import EAPTrace
from sentry.services.eventstore.models import Event, GroupEvent
from sentry.snuba.referrer import Referrer
from sentry.snuba.spans_rpc import Spans
from sentry.snuba.trace import query_trace_data
from sentry.snuba.utils import get_dataset
from sentry.utils.dates import parse_stats_period

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -175,6 +177,151 @@ def execute_trace_query_table(
return resp.data


def execute_table_query(
*,
org_id: int,
dataset: str,
fields: list[str],
query: str,
sort: str,
per_page: int,
stats_period: str,
project_ids: list[int] | None = None,
project_slugs: list[str] | None = None,
sampling_mode: SAMPLING_MODES = "NORMAL",
) -> dict[str, Any] | None:
"""
Execute a query to get table data by calling the events endpoint.

Arg notes:
project_ids: The IDs of the projects to query. Cannot be provided with project_slugs.
project_slugs: The slugs of the projects to query. Cannot be provided with project_ids.
If neither project_ids nor project_slugs are provided, all active projects will be queried.
"""
try:
organization = Organization.objects.get(id=org_id)
except Organization.DoesNotExist:
logger.warning("Organization not found", extra={"org_id": org_id})
return None

if not project_ids and not project_slugs:
project_ids = [ALL_ACCESS_PROJECT_ID]
# Note if both project_ids and project_slugs are provided, the API request will 400.

params: dict[str, Any] = {
"dataset": dataset,
"field": fields,
"query": query,
"sort": sort if sort else ("-timestamp" if "timestamp" in fields else None),
"per_page": per_page,
"statsPeriod": stats_period,
"project": project_ids,
"projectSlug": project_slugs,
"sampling": sampling_mode,
"referrer": Referrer.SEER_RPC,
}

# Remove None values
params = {k: v for k, v in params.items() if v is not None}

# Call sentry API client. This will raise API errors for non-2xx / 3xx status.
resp = client.get(
auth=ApiKey(organization_id=organization.id, scope_list=["org:read", "project:read"]),
user=None,
path=f"/organizations/{organization.slug}/events/",
params=params,
)
return resp.data


def execute_timeseries_query(
*,
org_id: int,
dataset: str,
y_axes: list[str],
group_by: list[str] | None = None,
query: str,
stats_period: str,
interval: str | None = None,
project_ids: list[int] | None = None,
project_slugs: list[str] | None = None,
sampling_mode: SAMPLING_MODES = "NORMAL",
partial: Literal["0", "1"] | None = None,
Copy link
Member Author

Choose a reason for hiding this comment

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

only used by issues event timeseries atm

) -> dict[str, Any] | None:
"""
Execute a query to get chart/timeseries data by calling the events-stats endpoint.

Arg notes:
interval: The interval of each bucket. Valid stats period format, e.g. '3h'.
partial: Whether to allow partial buckets if the last bucket does not align with rollup.
project_ids: The IDs of the projects to query. Cannot be provided with project_slugs.
project_slugs: The slugs of the projects to query. Cannot be provided with project_ids.
If neither project_ids nor project_slugs are provided, all active projects will be queried.
"""
try:
organization = Organization.objects.get(id=org_id)
except Organization.DoesNotExist:
logger.warning("Organization not found", extra={"org_id": org_id})
return None

group_by = group_by or []
if not project_ids and not project_slugs:
project_ids = [ALL_ACCESS_PROJECT_ID]
# Note if both project_ids and project_slugs are provided, the API request will 400.

params: dict[str, Any] = {
"dataset": dataset,
"yAxis": y_axes,
"field": y_axes + group_by,
"query": query,
"statsPeriod": stats_period,
"interval": interval,
"project": project_ids,
"projectSlug": project_slugs,
"sampling": sampling_mode,
"referrer": Referrer.SEER_RPC,
"partial": partial,
"excludeOther": "0", # Always include "Other" series
}

# Add top_events if group_by is provided
if group_by and get_dataset(dataset) in TOP_EVENTS_DATASETS:
params["topEvents"] = 5

# Remove None values
params = {k: v for k, v in params.items() if v is not None}

# Call sentry API client. This will raise API errors for non-2xx / 3xx status.
resp = client.get(
auth=ApiKey(organization_id=organization.id, scope_list=["org:read", "project:read"]),
user=None,
path=f"/organizations/{organization.slug}/events-stats/",
params=params,
)
data = resp.data

# Always normalize to the nested {"metric": {"data": [...]}} format for consistency
metric_is_single = len(y_axes) == 1
metric_name = y_axes[0] if metric_is_single else None
if metric_name and metric_is_single:
# Handle grouped data with single metric: wrap each group's data in the metric name
if group_by:
return {
group_value: (
{metric_name: group_data}
if isinstance(group_data, dict) and "data" in group_data
else group_data
)
for group_value, group_data in data.items()
}

# Handle non-grouped data with single metric: wrap data in the metric name
if isinstance(data, dict) and "data" in data:
return {metric_name: data}

return data


def get_trace_waterfall(trace_id: str, organization_id: int) -> EAPTrace | None:
"""
Get the full span waterfall and connected errors for a trace.
Expand Down Expand Up @@ -352,7 +499,8 @@ def _get_issue_event_timeseries(
first_seen_delta: timedelta,
) -> tuple[dict[str, Any], str, str] | None:
"""
Get event counts over time for an issue by calling the events-stats endpoint.
Get event counts over time for an issue (no group by) by calling the events-stats endpoint. Dynamically picks
a stats period and interval based on the issue's first seen date and EVENT_TIMESERIES_RESOLUTIONS.
"""

stats_period, interval = None, None
Expand All @@ -364,35 +512,21 @@ def _get_issue_event_timeseries(
stats_period = stats_period or "90d"
interval = interval or "3d"

params: dict[str, Any] = {
"dataset": "issuePlatform",
"query": f"issue:{issue_short_id}",
"yAxis": "count()",
"partial": "1",
"statsPeriod": stats_period,
"interval": interval,
"project": project_id,
"referrer": Referrer.SEER_RPC,
}

resp = client.get(
auth=ApiKey(organization_id=organization.id, scope_list=["org:read", "project:read"]),
user=None,
path=f"/organizations/{organization.slug}/events-stats/",
params=params,
data = execute_timeseries_query(
org_id=organization.id,
dataset="issuePlatform",
y_axes=["count()"],
group_by=[],
query=f"issue:{issue_short_id}",
stats_period=stats_period,
interval=interval,
project_ids=[project_id],
partial="1",
)
if resp.status_code != 200 or not (resp.data or {}).get("data"):
logger.warning(
"Failed to get event counts for issue",
extra={
"organization_slug": organization.slug,
"project_id": project_id,
"issue_id": issue_short_id,
},
)
return None

return {"count()": {"data": resp.data["data"]}}, stats_period, interval
if data is None:
return None
return data, stats_period, interval


def get_issue_details(
Expand Down
Loading