Skip to content
5 changes: 5 additions & 0 deletions src/sentry/apidocs/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ def get_old_json_components(filename: str) -> Any:
"description": "Private endpoints for investigation notebooks and agent workflows. Requires the organizations:investigations feature and open organization membership. Notebook and workflow versions are independent optimistic concurrency tokens.",
"x-display-description": True,
},
{
"name": "Notebooks",
"description": "Endpoints for notebooks, which people and Seer agents write. Requires the organizations:investigations feature and open organization membership. A notebook's version is an optimistic concurrency token.",
"x-display-description": True,
},
{
"name": "Teams",
"description": "Endpoints for teams",
Expand Down
85 changes: 85 additions & 0 deletions src/sentry/apidocs/examples/notebook_examples.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
from drf_spectacular.utils import OpenApiExample

BLOCK: dict[str, object] = {
"id": "12",
"position": 0,
"kind": "text",
"title": "Summary",
"content": "Checkout latency rose after the 2.4.0 release.",
"generationPrompt": "",
"generatedContent": "Checkout latency rose after the 2.4.0 release.",
"output": {
"schemaVersion": 1,
"markdown": "Checkout latency rose after the 2.4.0 release.",
},
"outputStatus": "available",
"currentExecution": {
"id": "40",
"status": "completed",
"executor": "code_mode",
"schemaVersion": 1,
"startedAt": "2026-11-10T18:00:00.000000Z",
"completedAt": "2026-11-10T18:00:00.000000Z",
"error": None,
},
"config": {},
"display": {"type": "markdown"},
"dependencies": [],
"parameterKeys": [],
"version": 1,
"staleAt": None,
"createdBy": None,
"lastEditedBy": None,
"producerKey": "summary",
"producerRevision": 1,
}

NOTEBOOK: dict[str, object] = {
"id": "7",
"title": "Checkout latency",
"summary": None,
"summaryDescription": None,
"status": "active",
"sourceType": "manual",
"createdBy": "1",
"creator": {"type": "user", "id": "1"},
"dateCreated": "2026-11-10T17:55:00.000000Z",
"dateUpdated": "2026-11-10T18:00:00.000000Z",
"version": 2,
"blockCount": 1,
"isFavorited": False,
"titleGeneration": {"status": None},
"orchestration": None,
"template": None,
"source": {"type": "manual", "ref": {}, "revision": None},
"filters": {},
"projectIds": [1],
"parameters": [],
"blocks": [BLOCK],
"actionRequests": [],
}


class NotebookExamples:
NOTEBOOK_DETAILS = [
OpenApiExample("Notebook details", value=NOTEBOOK, status_codes=["200"], response_only=True)
]
CREATE_NOTEBOOK = [
OpenApiExample(
"Created notebook",
value={**NOTEBOOK, "version": 1, "blockCount": 0, "blocks": []},
status_codes=["201"],
response_only=True,
),
OpenApiExample(
"Existing notebook for the source",
value=NOTEBOOK,
status_codes=["200"],
response_only=True,
),
]
BLOCK_DETAILS = [OpenApiExample("Block", value=BLOCK, status_codes=["200"], response_only=True)]
CREATE_BLOCK = [
OpenApiExample("Created block", value=BLOCK, status_codes=["201"], response_only=True),
OpenApiExample("Updated block", value=BLOCK, status_codes=["200"], response_only=True),
]
8 changes: 5 additions & 3 deletions src/sentry/investigations/apidocs.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@


def investigation_endpoints(endpoints: Any) -> Any:
# Published operations are in the public schema.
return [
endpoint
for endpoint in endpoints
if issubclass(endpoint[3].view_class, OrganizationNotebooksBaseEndpoint)
(path, path_regex, method, callback)
for path, path_regex, method, callback in endpoints
if issubclass(callback.view_class, OrganizationNotebooksBaseEndpoint)
and not callback.view_class.publish_status[method].is_published
]


Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
from __future__ import annotations

from collections.abc import Sequence
from typing import Literal, TypedDict, cast
from typing import Annotated, Literal, TypedDict, cast

from django.db import router, transaction
from django.db.models import QuerySet
from rest_framework import serializers

from sentry import features
from sentry.apidocs.descriptions import ApiDescription
from sentry.auth.access import Access
from sentry.auth.principal import RequestPrincipal
from sentry.models.organization import Organization
Expand Down Expand Up @@ -36,7 +37,9 @@ class ConnectTelemetryActionSerializer(ActionPayloadSerializer):


class ConnectTelemetryAppliedDetails(TypedDict):
provider: TelemetryProvider
provider: Annotated[
TelemetryProvider, ApiDescription("The telemetry provider that was connected")
]


def telemetry_available(organization: Organization, provider: TelemetryProvider) -> bool:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@
from collections import defaultdict
from collections.abc import Collection, Mapping, Sequence
from dataclasses import dataclass
from typing import Any, Literal, TypedDict, cast
from typing import Annotated, Any, Literal, TypedDict, cast

from django.db import router, transaction
from rest_framework import serializers

from sentry.api.exceptions import ResourceDoesNotExist
from sentry.api.serializers import serialize
from sentry.apidocs.descriptions import ApiDescription
from sentry.auth.access import Access
from sentry.auth.principal import RequestPrincipal
from sentry.db.models.fields.bounded import I32_MAX, I64_MAX
Expand Down Expand Up @@ -354,9 +355,9 @@ def _action_detector_id(action_request: NotebookActionRequest) -> int:


class UpdateDetectorAppliedDetails(TypedDict):
detectorId: str
detectorName: str
fields: list[str]
detectorId: Annotated[str, ApiDescription("The ID of the monitor that was changed")]
detectorName: Annotated[str, ApiDescription("The name of the monitor that was changed")]
fields: Annotated[list[str], ApiDescription("The monitor settings that were changed")]


def _error_messages(errors: Mapping[str, Any] | Sequence[Any] | str) -> list[str]:
Expand Down
5 changes: 4 additions & 1 deletion src/sentry/notebooks/endpoints/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from sentry.api.bases.organization import OrganizationEndpoint, OrganizationPermission
from sentry.api.exceptions import ResourceDoesNotExist
from sentry.api.helpers.creator import get_creator_reference
from sentry.apidocs.response_types import DetailResponse, ValidationErrorResponse
from sentry.auth.access import ServiceAccountAccess
from sentry.auth.principal import acting_user_id
from sentry.constants import ObjectStatus
Expand All @@ -34,7 +35,9 @@ class NotebookArchivedError(APIException):
default_detail = "Archived investigations are read-only."


def service_error(error: Exception) -> Response | None:
def service_error(
error: Exception,
) -> Response[ValidationErrorResponse] | Response[DetailResponse] | None:
if isinstance(error, NotebookValidationError):
return Response(error.errors, status=status.HTTP_400_BAD_REQUEST)
if isinstance(error, NotebookConflictError):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,13 @@
from sentry.api.exceptions import ResourceDoesNotExist
from sentry.api.serializers import serialize
from sentry.apidocs import constants
from sentry.apidocs.examples.notebook_examples import NotebookExamples
from sentry.apidocs.parameters import GlobalParams, NotebookParams
from sentry.apidocs.response_types import (
DetailResponse,
ValidationErrorResponse,
as_validation_errors,
)
from sentry.apidocs.utils import inline_sentry_response_serializer
from sentry.models.organization import Organization
from sentry.notebooks.endpoints.base import (
Expand All @@ -32,10 +38,15 @@
@cell_silo_endpoint
class OrganizationNotebookBlockDetailsEndpoint(OrganizationNotebookBlockEndpoint):
owner = ApiOwner.ML_AI
publish_status = {"PUT": ApiPublishStatus.PRIVATE, "DELETE": ApiPublishStatus.PRIVATE}
publish_status = {
"PUT": ApiPublishStatus.PUBLIC_EXPERIMENTAL,
"DELETE": ApiPublishStatus.PUBLIC_EXPERIMENTAL,
}
idempotent_methods = frozenset()

@extend_schema(
operation_id="Update an Investigation Block",
operation_id="Update a Notebook Block",
tags=["Notebooks"],
parameters=[
GlobalParams.ORG_ID_OR_SLUG,
NotebookParams.NOTEBOOK_ID,
Expand All @@ -44,7 +55,7 @@ class OrganizationNotebookBlockDetailsEndpoint(OrganizationNotebookBlockEndpoint
request=BlockUpdateValidator,
responses={
200: inline_sentry_response_serializer(
"InvestigationBlockResponse", NotebookBlockSerializerResponse
"NotebookBlockResponse", NotebookBlockSerializerResponse
),
400: constants.RESPONSE_BAD_REQUEST,
401: constants.RESPONSE_UNAUTHORIZED,
Expand All @@ -58,15 +69,20 @@ class OrganizationNotebookBlockDetailsEndpoint(OrganizationNotebookBlockEndpoint
value={"notebookVersion": 2, "version": 1, "content": "Revised observations"},
request_only=True,
)
],
]
+ NotebookExamples.BLOCK_DETAILS,
)
def put(
self,
request: Request,
organization: Organization,
notebook: Notebook,
block: NotebookBlock,
) -> Response:
) -> (
Response[NotebookBlockSerializerResponse]
| Response[ValidationErrorResponse]
| Response[DetailResponse]
):
"""
Update at least one of title, content, generationPrompt, config, or display. Send both
notebook and block versions. The block kind cannot change. Returns the updated block.
Expand All @@ -79,7 +95,7 @@ def put(

validator = BlockUpdateValidator(data=request.data, context={"block": block})
if not validator.is_valid():
return Response(validator.errors, status=status.HTTP_400_BAD_REQUEST)
return Response(as_validation_errors(validator), status=status.HTTP_400_BAD_REQUEST)

values = dict(validator.validated_data)
expected_notebook_version = values.pop("notebook_version")
Expand Down Expand Up @@ -109,7 +125,8 @@ def put(
)

@extend_schema(
operation_id="Delete an Investigation Block",
operation_id="Delete a Notebook Block",
tags=["Notebooks"],
parameters=[
GlobalParams.ORG_ID_OR_SLUG,
NotebookParams.NOTEBOOK_ID,
Expand Down Expand Up @@ -138,7 +155,7 @@ def delete(
organization: Organization,
notebook: Notebook,
block: NotebookBlock,
) -> Response:
) -> Response[None] | Response[ValidationErrorResponse] | Response[DetailResponse]:
"""
Soft-delete an active block using notebook and block versions. Active executions and
other state conflicts return 409. Returns no body. Requires a user or service account.
Expand All @@ -150,7 +167,7 @@ def delete(

validator = BlockDeleteValidator(data=request.data)
if not validator.is_valid():
return Response(validator.errors, status=status.HTTP_400_BAD_REQUEST)
return Response(as_validation_errors(validator), status=status.HTTP_400_BAD_REQUEST)
try:
delete_block(
block=block,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,13 @@
from sentry.api.base import cell_silo_endpoint
from sentry.api.serializers import serialize
from sentry.apidocs import constants
from sentry.apidocs.examples.notebook_examples import NotebookExamples
from sentry.apidocs.parameters import GlobalParams, NotebookParams
from sentry.apidocs.response_types import (
DetailResponse,
ValidationErrorResponse,
as_validation_errors,
)
from sentry.apidocs.utils import inline_sentry_response_serializer
from sentry.models.organization import Organization
from sentry.notebooks.endpoints.base import (
Expand All @@ -32,18 +38,20 @@
@cell_silo_endpoint
class OrganizationNotebookBlockIndexEndpoint(OrganizationNotebookEndpoint):
owner = ApiOwner.ML_AI
publish_status = {"POST": ApiPublishStatus.PRIVATE}
publish_status = {"POST": ApiPublishStatus.PUBLIC_EXPERIMENTAL}
idempotent_methods = frozenset()

@extend_schema(
operation_id="Create an Investigation Block",
operation_id="Create a Notebook Block",
tags=["Notebooks"],
parameters=[GlobalParams.ORG_ID_OR_SLUG, NotebookParams.NOTEBOOK_ID],
request=BlockCreateValidator,
responses={
200: inline_sentry_response_serializer(
"InvestigationBlockResponse", NotebookBlockSerializerResponse
"NotebookBlockResponse", NotebookBlockSerializerResponse
),
201: inline_sentry_response_serializer(
"InvestigationBlockResponse", NotebookBlockSerializerResponse
"NotebookBlockResponse", NotebookBlockSerializerResponse
),
400: constants.RESPONSE_BAD_REQUEST,
401: constants.RESPONSE_UNAUTHORIZED,
Expand Down Expand Up @@ -71,9 +79,16 @@ class OrganizationNotebookBlockIndexEndpoint(OrganizationNotebookEndpoint):
},
request_only=True,
),
],
]
+ NotebookExamples.CREATE_BLOCK,
)
def post(self, request: Request, organization: Organization, notebook: Notebook) -> Response:
def post(
self, request: Request, organization: Organization, notebook: Notebook
) -> (
Response[NotebookBlockSerializerResponse]
| Response[ValidationErrorResponse]
| Response[DetailResponse]
):
"""
Append a text or query block to an active investigation, using notebookVersion for
concurrency. A producer sends key and revision instead to write its text block: the
Expand All @@ -95,7 +110,7 @@ def post(self, request: Request, organization: Organization, notebook: Notebook)
},
)
if not validator.is_valid():
return Response(validator.errors, status=status.HTTP_400_BAD_REQUEST)
return Response(as_validation_errors(validator), status=status.HTTP_400_BAD_REQUEST)
try:
block, created = validator.save()
except Exception as error:
Expand Down
Loading
Loading