Skip to content
77 changes: 75 additions & 2 deletions src/sentry/api/endpoints/organization_events.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import logging

import sentry_sdk
from drf_spectacular.utils import OpenApiExample, OpenApiResponse, extend_schema
from rest_framework.exceptions import ParseError
from rest_framework.request import Request
from rest_framework.response import Response
Expand All @@ -9,6 +10,9 @@
from sentry.api.bases import NoProjects, OrganizationEventsV2EndpointBase
from sentry.api.paginator import GenericOffsetPaginator
from sentry.api.utils import InvalidParams
from sentry.apidocs import constants as api_constants
from sentry.apidocs.parameters import GLOBAL_PARAMS, VISIBILITY_PARAMS
from sentry.apidocs.utils import inline_sentry_response_serializer
from sentry.search.events.fields import is_function
from sentry.snuba import discover, metrics_enhanced_performance

Expand Down Expand Up @@ -47,6 +51,8 @@


class OrganizationEventsV2Endpoint(OrganizationEventsV2EndpointBase):
"""Deprecated in favour of OrganizationEventsEndpoint"""

def get(self, request: Request, organization) -> Response:
if not self.has_feature(organization, request):
return Response(status=404)
Expand Down Expand Up @@ -127,10 +133,77 @@ def data_fn(offset, limit):
)


@extend_schema(tags=["Discover"])
class OrganizationEventsEndpoint(OrganizationEventsV2EndpointBase):
private = True

public = {"GET"}

@extend_schema(
operation_id="Query Discover Events in Table Format",
parameters=[
VISIBILITY_PARAMS.QUERY,
VISIBILITY_PARAMS.FIELD,
VISIBILITY_PARAMS.SORT,
VISIBILITY_PARAMS.PER_PAGE,
GLOBAL_PARAMS.STATS_PERIOD,
GLOBAL_PARAMS.START,
GLOBAL_PARAMS.END,
GLOBAL_PARAMS.PROJECT,
GLOBAL_PARAMS.ENVIRONMENT,
],
responses={
200: inline_sentry_response_serializer(
"OrganizationEventsResponseDict", discover.EventsResponse
),
400: OpenApiResponse(description="Invalid Query"),
404: api_constants.RESPONSE_NOTFOUND,
},
examples=[
OpenApiExample(
"Success",
value={
"data": [
{
"count_if(transaction.duration,greater,300)": 5,
"count()": 10,
"equation|count_if(transaction.duration,greater,300) / count() * 100": 50,
"transaction": "foo",
},
{
"count_if(transaction.duration,greater,300)": 3,
"count()": 20,
"equation|count_if(transaction.duration,greater,300) / count() * 100": 15,
"transaction": "bar",
},
{
"count_if(transaction.duration,greater,300)": 8,
"count()": 40,
"equation|count_if(transaction.duration,greater,300) / count() * 100": 20,
"transaction": "baz",
},
],
"meta": {
"fields": {
"count_if(transaction.duration,greater,300)": "integer",
"count()": "integer",
"equation|count_if(transaction.duration,greater,300) / count() * 100": "number",
"transaction": "string",
},
},
},
)
],
)
def get(self, request: Request, organization) -> Response:
"""
Retrieves discover (also known as events) data for a given organization.

**Note**: This endpoint is intended to get a table of results, and is not for doing a full export of data sent to
Sentry.

The `field` query parameter determines what fields will be selected in the `data` and `meta` keys of the endpoint response.
- The `data` key contains a list of results row by row that match the `query` made
- The `meta` key contains information about the response, including the unit or type of the fields requested
"""
if not self.has_feature(organization, request):
return Response(status=404)

Expand Down
12 changes: 12 additions & 0 deletions src/sentry/apidocs/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,4 +79,16 @@ def get_old_json_paths(filename: str) -> json.JSONData:
"url": "https://github.com/getsentry/sentry-docs/issues/new/?title=API%20Documentation%20Error:%20/api/integration-platform/&template=api_error_template.md",
},
},
{
# Not using visibility here since users won't be aware what that is, this "name" is only used in the URL so not
# a big deal that its missing Performance
"name": "Discover",
"x-sidebar-name": "Discover & Performance",
"description": "Discover and Performance allow you to slice and dice your Error and Transaction events",
"x-display-description": True,
"externalDocs": {
"description": "Found an error? Let us know.",
"url": "https://github.com/getsentry/sentry-docs/issues/new/?title=API%20Documentation%20Error:%20/api/integration-platform/&template=api_error_template.md",
},
},
]
90 changes: 90 additions & 0 deletions src/sentry/apidocs/parameters.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter
from rest_framework import serializers

Expand All @@ -17,6 +18,50 @@ class GLOBAL_PARAMS:
type=str,
location="path",
)
STATS_PERIOD = OpenApiParameter(
Copy link
Contributor

Choose a reason for hiding this comment

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

I think OpenApiParameter maps to URL parameters, not query parameters

Copy link
Contributor

Choose a reason for hiding this comment

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

drf serializers map to query parameters

Copy link
Member Author

Choose a reason for hiding this comment

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

I don't think so, its what the location param on the OpenApiParameter is for so you can define whether the parameter is a part of the path or the query

Based on docs you can do header, cookie or form parameters too:
https://drf-spectacular.readthedocs.io/en/latest/drf_yasg.html?highlight=location#parameter-location

name="statsPeriod",
location="query",
required=False,
type=str,
description="""The period of time for the query, will override the start & end parameters, a number followed by one of:
- `d` for days
- `h` for hours
- `m` for minutes
- `s` for seconds
- `w` for weeks

For example `24h`, to mean query data starting from 24 hours ago to now.""",
)
START = OpenApiParameter(
name="start",
location="query",
required=False,
type=OpenApiTypes.DATETIME,
description="The start of the period of time for the query, expected in ISO-8601 format. For example `2001-12-14T12:34:56.7890`",
)
END = OpenApiParameter(
name="end",
location="query",
required=False,
type=OpenApiTypes.DATETIME,
description="The end of the period of time for the query, expected in ISO-8601 format. For example `2001-12-14T12:34:56.7890`",
)
PROJECT = OpenApiParameter(
name="project",
location="query",
required=False,
many=True,
type=int,
description="The ids of projects to filter by. `-1` means all available projects. If this parameter is omitted, the request will default to using 'My Projects'",
)
ENVIRONMENT = OpenApiParameter(
name="environment",
location="query",
required=False,
many=True,
type=str,
description="The name of environments to filter by.",
)


class SCIM_PARAMS:
Expand Down Expand Up @@ -46,6 +91,51 @@ class ISSUE_ALERT_PARAMS:
)


class VISIBILITY_PARAMS:
QUERY = OpenApiParameter(
Copy link
Contributor

Choose a reason for hiding this comment

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

Is this a visibility-only param? I think we use it in several places outside of visibility.

Copy link
Member Author

Choose a reason for hiding this comment

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

This is a specific Query to Visibility (see the bit about the search syntax)

name="query",
location="query",
required=False,
type=str,
description="""The search filter for your query, read more about query syntax [here](https://docs.sentry.io/product/sentry-basics/search/)

example: `query=(transaction:foo AND release:abc) OR (transaction:[bar,baz] AND release:def)`
""",
)
FIELD = OpenApiParameter(
name="field",
location="query",
required=True,
type=str,
many=True,
description="""The fields, functions, or equations to request for the query. At most 20 fields can be selected per request. Each field can be one of the following types:
- A built-in key field. See possible fields in the [properties table](/product/sentry-basics/search/searchable-properties/#properties-table), under any field that is an event property
- example: `field=transaction`
- A tag. Tags should use the `tag[]` formatting to avoid ambiguity with any fields
- example: `field=tag[isEnterprise]`
- A function which will be in the format of `function_name(parameters,...)`. See possible functions in the [query builder documentation](/product/discover-queries/query-builder/#stacking-functions)
- when a function is included, Discover will group by any tags or fields
- example: `field=count_if(transaction.duration,greater,300)`
- An equation when prefixed with `equation|`. Read more about [equations here](https://docs.sentry.io/product/discover-queries/query-builder/query-equations/)
- example: `field=equation|count_if(transaction.duration,greater,300) / count() * 100`
""",
)
SORT = OpenApiParameter(
name="sort",
location="query",
required=False,
type=str,
description="What to order the results of the query by. Must be something in the `field` list, excluding equations.",
)
PER_PAGE = OpenApiParameter(
name="per_page",
location="query",
required=False,
type=int,
description="Limit the number of rows to return in the result. Default and maximum allowed is 100.",
)


class CURSOR_QUERY_PARAM(serializers.Serializer): # type: ignore
cursor = serializers.CharField(
help_text="A pointer to the last object fetched and its' sort order; used to retrieve the next or previous results.",
Expand Down
42 changes: 30 additions & 12 deletions src/sentry/snuba/discover.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@
from collections import namedtuple
from copy import deepcopy
from datetime import timedelta
from typing import Dict, Optional, Sequence
from typing import Any, Dict, List, Optional, Sequence

import sentry_sdk
from dateutil.parser import parse as parse_datetime
from snuba_sdk.conditions import Condition, Op
from snuba_sdk.function import Function
from typing_extensions import TypedDict

from sentry.discover.arithmetic import categorize_columns
from sentry.models import Group
Expand Down Expand Up @@ -67,6 +68,16 @@
PaginationResult = namedtuple("PaginationResult", ["next", "previous", "oldest", "latest"])
FacetResult = namedtuple("FacetResult", ["key", "value", "count"])


class EventsMeta(TypedDict):
fields: Dict[str, str]


class EventsResponse(TypedDict):
data: List[Dict[str, Any]]
meta: EventsMeta


resolve_discover_column = resolve_column(Dataset.Discover)

OTHER_KEY = "Other"
Expand Down Expand Up @@ -128,14 +139,16 @@ def zerofill(data, start, end, rollup, orderby):
return rv


def transform_results(results, function_alias_map, translated_columns, snuba_filter):
def transform_results(
results, function_alias_map, translated_columns, snuba_filter
) -> EventsResponse:
results = transform_data(results, translated_columns, snuba_filter)
results["meta"] = transform_meta(results, function_alias_map)
return results


def transform_meta(results, function_alias_map):
meta = {
def transform_meta(results: EventsResponse, function_alias_map) -> Dict[str, str]:
meta: Dict[str, str] = {
value["name"]: get_json_meta_type(
value["name"], value.get("type"), function_alias_map.get(value["name"])
)
Expand All @@ -149,14 +162,15 @@ def transform_meta(results, function_alias_map):
return meta


def transform_data(result, translated_columns, snuba_filter):
def transform_data(result, translated_columns, snuba_filter) -> EventsResponse:
"""
Transform internal names back to the public schema ones.

When getting timeseries results via rollup, this function will
zerofill the output results.
"""
for col in result["meta"]:
final_result: EventsResponse = {"data": result["data"], "meta": result["meta"]}
for col in final_result["meta"]:
# Translate back column names that were converted to snuba format
col["name"] = translated_columns.get(col["name"], col["name"])

Expand All @@ -174,19 +188,23 @@ def get_row(row):

return transformed

result["data"] = [get_row(row) for row in result["data"]]
final_result["data"] = [get_row(row) for row in final_result["data"]]

if snuba_filter and snuba_filter.rollup and snuba_filter.rollup > 0:
rollup = snuba_filter.rollup
with sentry_sdk.start_span(
op="discover.discover", description="transform_results.zerofill"
) as span:
span.set_data("result_count", len(result.get("data", [])))
result["data"] = zerofill(
result["data"], snuba_filter.start, snuba_filter.end, rollup, snuba_filter.orderby
span.set_data("result_count", len(final_result.get("data", [])))
final_result["data"] = zerofill(
final_result["data"],
snuba_filter.start,
snuba_filter.end,
rollup,
snuba_filter.orderby,
)

return result
return final_result


def transform_tips(tips):
Expand All @@ -213,7 +231,7 @@ def query(
conditions=None,
functions_acl=None,
transform_alias_to_input_format=False,
):
) -> EventsResponse:
"""
High-level API for doing arbitrary user queries against events.

Expand Down