diff --git a/docs/openapi/monitoring-api.json b/docs/openapi/monitoring-api.json index d25734a..0ce2ea6 100644 --- a/docs/openapi/monitoring-api.json +++ b/docs/openapi/monitoring-api.json @@ -58,6 +58,10 @@ "name": "Environments", "description": "Variable namespace management for monitors" }, + { + "name": "Forensics", + "description": "Detection engine event-sourced history (policy snapshots, rule evaluations, state transitions)" + }, { "name": "Heartbeat", "description": "Public ping endpoint for heartbeat monitors" @@ -2900,51 +2904,659 @@ } } }, - "502": { - "description": "Bad gateway — an upstream provider returned an error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } + "502": { + "description": "Bad gateway — an upstream provider returned an error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "503": { + "description": "Service unavailable — try again shortly", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + }, + "post": { + "tags": [ + "Environments" + ], + "summary": "Create environment", + "operationId": "create_13", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateEnvironmentRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Created", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/SingleValueResponseEnvironmentDto" + } + } + } + }, + "400": { + "description": "Bad request — the payload failed validation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized — missing or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden — the actor lacks permission for this resource", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found — the requested resource does not exist", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "409": { + "description": "Conflict — the request collides with current resource state", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error — see the message field for details", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "502": { + "description": "Bad gateway — an upstream provider returned an error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "503": { + "description": "Service unavailable — try again shortly", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/api/v1/environments/{slug}": { + "get": { + "tags": [ + "Environments" + ], + "summary": "Get environment by slug", + "operationId": "get_7", + "parameters": [ + { + "name": "slug", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/SingleValueResponseEnvironmentDto" + } + } + } + }, + "400": { + "description": "Bad request — the payload failed validation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized — missing or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden — the actor lacks permission for this resource", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found — the requested resource does not exist", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "409": { + "description": "Conflict — the request collides with current resource state", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error — see the message field for details", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "502": { + "description": "Bad gateway — an upstream provider returned an error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "503": { + "description": "Service unavailable — try again shortly", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + }, + "put": { + "tags": [ + "Environments" + ], + "summary": "Update environment", + "operationId": "update_13", + "parameters": [ + { + "name": "slug", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateEnvironmentRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/SingleValueResponseEnvironmentDto" + } + } + } + }, + "400": { + "description": "Bad request — the payload failed validation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized — missing or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden — the actor lacks permission for this resource", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found — the requested resource does not exist", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "409": { + "description": "Conflict — the request collides with current resource state", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error — see the message field for details", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "502": { + "description": "Bad gateway — an upstream provider returned an error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "503": { + "description": "Service unavailable — try again shortly", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + }, + "delete": { + "tags": [ + "Environments" + ], + "summary": "Delete environment", + "operationId": "delete_9", + "parameters": [ + { + "name": "slug", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad request — the payload failed validation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized — missing or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden — the actor lacks permission for this resource", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found — the requested resource does not exist", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "409": { + "description": "Conflict — the request collides with current resource state", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error — see the message field for details", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "502": { + "description": "Bad gateway — an upstream provider returned an error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "503": { + "description": "Service unavailable — try again shortly", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/api/v1/forensics/incidents/{id}/timeline": { + "get": { + "tags": [ + "Forensics" + ], + "summary": "Full forensic timeline for an incident", + "description": "Returns every state-machine transition for this incident plus the rule evaluations that caused each transition, plus the policy snapshot that triggered confirmation. Correlate evaluations to transitions via evaluation.triggeringTransitionId == transition.id.", + "operationId": "getTimeline", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/SingleValueResponseIncidentTimelineDto" + } + } + } + }, + "400": { + "description": "Bad request — the payload failed validation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized — missing or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden — the actor lacks permission for this resource", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found — the requested resource does not exist", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "409": { + "description": "Conflict — the request collides with current resource state", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error — see the message field for details", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "502": { + "description": "Bad gateway — an upstream provider returned an error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "503": { + "description": "Service unavailable — try again shortly", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/api/v1/forensics/monitors/{id}/rule-evaluations": { + "get": { + "tags": [ + "Forensics" + ], + "summary": "Paged list of rule evaluations for a monitor", + "description": "Filter by ruleType (e.g. consecutive_failures), region, onlyMatched=true to narrow to firing evaluations, and occurredAt window.", + "operationId": "listMonitorRuleEvaluations", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "ruleType", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "region", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "onlyMatched", + "in": "query", + "required": false, + "schema": { + "type": "boolean" + } + }, + { + "name": "from", + "in": "query", + "required": false, + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "to", + "in": "query", + "required": false, + "schema": { + "type": "string", + "format": "date-time" } }, - "503": { - "description": "Service unavailable — try again shortly", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } + { + "name": "pageable", + "in": "query", + "required": true, + "schema": { + "$ref": "#/components/schemas/Pageable" } } - } - }, - "post": { - "tags": [ - "Environments" ], - "summary": "Create environment", - "operationId": "create_13", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateEnvironmentRequest" - } - } - }, - "required": true - }, "responses": { - "201": { - "description": "Created", + "200": { + "description": "OK", "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/SingleValueResponseEnvironmentDto" + "$ref": "#/components/schemas/TableValueResultRuleEvaluationDto" } } } @@ -3032,20 +3644,47 @@ } } }, - "/api/v1/environments/{slug}": { + "/api/v1/forensics/monitors/{id}/transitions": { "get": { "tags": [ - "Environments" + "Forensics" ], - "summary": "Get environment by slug", - "operationId": "get_7", + "summary": "Paged list of state transitions for a monitor (optionally time-bounded)", + "operationId": "listMonitorTransitions", "parameters": [ { - "name": "slug", + "name": "id", "in": "path", "required": true, "schema": { - "type": "string" + "type": "string", + "format": "uuid" + } + }, + { + "name": "from", + "in": "query", + "required": false, + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "to", + "in": "query", + "required": false, + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "pageable", + "in": "query", + "required": true, + "schema": { + "$ref": "#/components/schemas/Pageable" } } ], @@ -3055,7 +3694,7 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/SingleValueResponseEnvironmentDto" + "$ref": "#/components/schemas/TableValueResultIncidentStateTransitionDto" } } } @@ -3141,16 +3780,19 @@ } } } - }, - "put": { + } + }, + "/api/v1/forensics/policy-snapshots/{hashHex}": { + "get": { "tags": [ - "Environments" + "Forensics" ], - "summary": "Update environment", - "operationId": "update_13", + "summary": "Fetch a policy snapshot by its content hash", + "description": "Hash is SHA-256 over canonical policy JSON, hex-encoded. Access is gated: caller's org must have evaluated against this hash at least once.", + "operationId": "getPolicySnapshot", "parameters": [ { - "name": "slug", + "name": "hashHex", "in": "path", "required": true, "schema": { @@ -3158,23 +3800,13 @@ } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateEnvironmentRequest" - } - } - }, - "required": true - }, "responses": { "200": { "description": "OK", "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/SingleValueResponseEnvironmentDto" + "$ref": "#/components/schemas/SingleValueResponsePolicySnapshotDto" } } } @@ -3260,26 +3892,37 @@ } } } - }, - "delete": { + } + }, + "/api/v1/forensics/traces/{checkId}": { + "get": { "tags": [ - "Environments" + "Forensics" ], - "summary": "Delete environment", - "operationId": "delete_9", + "summary": "Replay a single check execution", + "description": "Returns every rule evaluation and state transition emitted for this scheduler-minted check_id (V92), plus the policy snapshot that governed them.", + "operationId": "getTrace", "parameters": [ { - "name": "slug", + "name": "checkId", "in": "path", "required": true, "schema": { - "type": "string" + "type": "string", + "format": "uuid" } } ], "responses": { - "204": { - "description": "No Content" + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/SingleValueResponseCheckTraceDto" + } + } + } }, "400": { "description": "Bad request — the payload failed validation", @@ -21502,14 +22145,12 @@ "nullable": true }, "metadata": { - "type": "object", - "additionalProperties": { - "type": "object", - "description": "Additional context about the action", - "nullable": true - }, - "description": "Additional context about the action", - "nullable": true + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/AuditMetadata" + } + ] }, "createdAt": { "type": "string", @@ -21518,6 +22159,21 @@ } } }, + "AuditMetadata": { + "description": "Typed metadata payload attached to an audit event; null for actions that carry no extra context.", + "nullable": true, + "discriminator": { + "propertyName": "kind", + "mapping": { + "member_role_changed": "#/components/schemas/MemberRoleChangedMetadata" + } + }, + "oneOf": [ + { + "$ref": "#/components/schemas/MemberRoleChangedMetadata" + } + ] + }, "AuthMeResponse": { "required": [ "key", @@ -21938,6 +22594,43 @@ }, "description": "A single check result from a monitor run" }, + "CheckTraceDto": { + "required": [ + "checkId", + "evaluations", + "transitions" + ], + "type": "object", + "properties": { + "checkId": { + "type": "string", + "description": "The check execution ID this trace is keyed by", + "format": "uuid" + }, + "evaluations": { + "type": "array", + "description": "All rule evaluations that ran for this check", + "items": { + "$ref": "#/components/schemas/RuleEvaluationDto" + } + }, + "transitions": { + "type": "array", + "description": "State-machine transitions this check caused (may be empty if nothing fired)", + "items": { + "$ref": "#/components/schemas/IncidentStateTransitionDto" + } + }, + "policySnapshot": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/PolicySnapshotDto" + } + ] + } + } + }, "CheckTypeDetailsDto": { "description": "Check-type-specific details — polymorphic by check_type discriminator", "discriminator": { @@ -24935,6 +25628,28 @@ "type": "string", "description": "Name of the resource group; populated on list responses. Omitted from JSON (undefined to SDKs) on detail responses, treat missing as null.", "nullable": true + }, + "triggeringCheckId": { + "type": "string", + "description": "Scheduler-minted check execution ID whose result confirmed this incident; joins to check_results, rule_evaluations, and incident_state_transitions", + "format": "uuid", + "nullable": true + }, + "triggeredByRuleSnapshotHashHex": { + "type": "string", + "description": "Hex SHA-256 of the canonical policy snapshot that fired; combined with triggeredByRuleIndex points to the exact TriggerRule", + "nullable": true + }, + "triggeredByRuleIndex": { + "type": "integer", + "description": "Index of the fired rule inside the policy's trigger_rules array", + "format": "int32", + "nullable": true + }, + "engineVersion": { + "type": "string", + "description": "Detection engine semver that evaluated the rule", + "nullable": true } }, "description": "Incident triggered by a monitor check failure or manual creation" @@ -25108,42 +25823,160 @@ "properties": { "id": { "type": "string", - "description": "Internal incident ID — UUID for status-page incidents, service incident UUID for catalog", - "format": "uuid" + "description": "Internal incident ID — UUID for status-page incidents, service incident UUID for catalog", + "format": "uuid" + }, + "title": { + "type": "string", + "description": "Incident title at the time of the overlap" + }, + "impact": { + "type": "string", + "description": "Incident impact level (e.g. minor, major, critical for catalog; NONE/MINOR/MAJOR/CRITICAL for status pages)" + } + }, + "description": "Lightweight reference to an incident overlapping a given uptime day" + }, + "IncidentsSummaryDto": { + "type": "object", + "properties": { + "active": { + "type": "integer", + "format": "int64" + }, + "resolvedToday": { + "type": "integer", + "format": "int64" + }, + "mttr30d": { + "type": "number", + "format": "double", + "nullable": true + } + }, + "description": "Incident summary counters", + "required": [ + "active", + "resolvedToday" + ] + }, + "IncidentStateTransitionDto": { + "required": [ + "affectedRegions", + "checkId", + "engineVersion", + "fromStatus", + "id", + "monitorId", + "occurredAt", + "policySnapshotHashHex", + "reason", + "toStatus", + "triggeringEvaluationIds" + ], + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Forensic row UUID", + "format": "uuid" + }, + "occurredAt": { + "type": "string", + "description": "When the state transition occurred", + "format": "date-time" + }, + "monitorId": { + "type": "string", + "description": "Monitor this transition pertains to", + "format": "uuid" + }, + "incidentId": { + "type": "string", + "description": "Incident this transition belongs to; null for pre-incident (auto-cleared) transitions", + "format": "uuid", + "nullable": true + }, + "fromStatus": { + "minLength": 1, + "type": "string", + "description": "Previous status (WATCHING | TRIGGERED | CONFIRMED | RESOLVED)" + }, + "toStatus": { + "minLength": 1, + "type": "string", + "description": "New status (WATCHING | TRIGGERED | CONFIRMED | RESOLVED)" + }, + "reason": { + "minLength": 1, + "type": "string", + "description": "Why the transition fired (rule_matched | confirmation_met | auto_cleared_by_timeout | recovery_met | reopened | manually_resolved | policy_changed)" }, - "title": { + "triggeringEvaluationIds": { + "type": "array", + "description": "rule_evaluation ids that caused this transition (may be empty for timeout-driven edges)", + "items": { + "type": "string", + "description": "rule_evaluation ids that caused this transition (may be empty for timeout-driven edges)", + "format": "uuid" + } + }, + "affectedRegions": { + "type": "array", + "description": "Regions whose evaluations contributed to this transition", + "items": { + "type": "string", + "description": "Regions whose evaluations contributed to this transition" + } + }, + "policySnapshotHashHex": { + "minLength": 1, "type": "string", - "description": "Incident title at the time of the overlap" + "description": "Hex-encoded hash of the policy snapshot that governed this transition" }, - "impact": { + "engineVersion": { + "minLength": 1, "type": "string", - "description": "Incident impact level (e.g. minor, major, critical for catalog; NONE/MINOR/MAJOR/CRITICAL for status pages)" + "description": "Detection engine version that emitted this transition" + }, + "checkId": { + "type": "string", + "description": "Scheduler-minted check execution ID (V92) of the triggering result", + "format": "uuid" } }, - "description": "Lightweight reference to an incident overlapping a given uptime day" + "description": "State-machine transitions this check caused (may be empty if nothing fired)" }, - "IncidentsSummaryDto": { + "IncidentTimelineDto": { + "required": [ + "transitions", + "triggeringEvaluations" + ], "type": "object", "properties": { - "active": { - "type": "integer", - "format": "int64" + "transitions": { + "type": "array", + "description": "State-machine transitions in chronological order", + "items": { + "$ref": "#/components/schemas/IncidentStateTransitionDto" + } }, - "resolvedToday": { - "type": "integer", - "format": "int64" + "triggeringEvaluations": { + "type": "array", + "description": "Rule evaluations that caused any of the transitions above. Correlate via evaluation.triggeringTransitionId == transition.id", + "items": { + "$ref": "#/components/schemas/RuleEvaluationDto" + } }, - "mttr30d": { - "type": "number", - "format": "double", - "nullable": true + "policySnapshot": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/PolicySnapshotDto" + } + ] } - }, - "description": "Incident summary counters", - "required": [ - "active", - "resolvedToday" - ] + } }, "IncidentUpdateDto": { "required": [ @@ -25965,6 +26798,41 @@ }, "description": "Organization member with role and status" }, + "MemberRoleChangedMetadata": { + "required": [ + "kind", + "oldRole", + "newRole" + ], + "type": "object", + "description": "Role transition recorded when an organization member's role changes.", + "properties": { + "kind": { + "type": "string", + "enum": [ + "member_role_changed" + ] + }, + "oldRole": { + "type": "string", + "description": "Role the member held before the change", + "enum": [ + "OWNER", + "ADMIN", + "MEMBER" + ] + }, + "newRole": { + "type": "string", + "description": "Role the member holds after the change", + "enum": [ + "OWNER", + "ADMIN", + "MEMBER" + ] + } + } + }, "MonitorAssertionDto": { "required": [ "assertionType", @@ -27096,6 +27964,48 @@ }, "description": "Billing plan and entitlement state" }, + "PolicySnapshotDto": { + "required": [ + "engineVersion", + "firstSeenAt", + "hashHex", + "lastSeenAt", + "policy" + ], + "type": "object", + "properties": { + "hashHex": { + "minLength": 1, + "type": "string", + "description": "Hex-encoded SHA-256 of the canonical policy JSON" + }, + "policy": { + "type": "object", + "additionalProperties": { + "type": "object", + "description": "Canonical policy document (snake_case, sorted keys)" + }, + "description": "Canonical policy document (snake_case, sorted keys)" + }, + "engineVersion": { + "minLength": 1, + "type": "string", + "description": "Detection engine version that observed this policy" + }, + "firstSeenAt": { + "type": "string", + "description": "First time the detection engine evaluated against this policy bytes", + "format": "date-time" + }, + "lastSeenAt": { + "type": "string", + "description": "Most recent time the engine evaluated against this policy bytes", + "format": "date-time" + } + }, + "description": "Policy snapshot used during this check (all evaluations of a single check are against one policy)", + "nullable": true + }, "PollChartBucketDto": { "required": [ "bucket", @@ -27860,6 +28770,105 @@ }, "description": "Default retry strategy for member monitors; null clears" }, + "RuleEvaluationDto": { + "required": [ + "checkId", + "engineVersion", + "evaluationDetails", + "id", + "inputResultIds", + "monitorId", + "occurredAt", + "policySnapshotHashHex", + "region", + "ruleScope", + "ruleType", + "ruleIndex", + "outputMatched" + ], + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Forensic row UUID", + "format": "uuid" + }, + "occurredAt": { + "type": "string", + "description": "When the evaluation ran", + "format": "date-time" + }, + "monitorId": { + "type": "string", + "description": "Monitor that produced the input check result", + "format": "uuid" + }, + "region": { + "minLength": 1, + "type": "string", + "description": "Probe region of the input check result" + }, + "policySnapshotHashHex": { + "minLength": 1, + "type": "string", + "description": "Hex-encoded hash of the policy snapshot this rule came from" + }, + "ruleIndex": { + "type": "integer", + "description": "Index into the policy's triggerRules array (0-based)", + "format": "int32" + }, + "ruleType": { + "minLength": 1, + "type": "string", + "description": "Rule type (e.g. consecutive_failures, failures_in_window)" + }, + "ruleScope": { + "minLength": 1, + "type": "string", + "description": "Rule scope (per_region | multi_region)" + }, + "inputResultIds": { + "minItems": 1, + "type": "array", + "description": "check_results IDs that were inputs to this evaluation (newest first)", + "items": { + "type": "string", + "description": "check_results IDs that were inputs to this evaluation (newest first)", + "format": "uuid" + } + }, + "outputMatched": { + "type": "boolean", + "description": "Whether the rule fired on this evaluation" + }, + "evaluationDetails": { + "type": "object", + "additionalProperties": { + "type": "object", + "description": "Structured details (e.g. failure counts, response-time aggregates)" + }, + "description": "Structured details (e.g. failure counts, response-time aggregates)" + }, + "engineVersion": { + "minLength": 1, + "type": "string", + "description": "Detection engine version that ran this evaluation" + }, + "checkId": { + "type": "string", + "description": "Scheduler-minted check execution ID (V92) — the causal chain identifier", + "format": "uuid" + }, + "triggeringTransitionId": { + "type": "string", + "description": "If this evaluation caused a state transition, points to that transition's id", + "format": "uuid", + "nullable": true + } + }, + "description": "All rule evaluations that ran for this check" + }, "ScheduledMaintenanceDto": { "required": [ "affectedComponents", @@ -28981,6 +29990,17 @@ } } }, + "SingleValueResponseCheckTraceDto": { + "required": [ + "data" + ], + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/CheckTraceDto" + } + } + }, "SingleValueResponseDashboardOverviewDto": { "required": [ "data" @@ -29058,6 +30078,17 @@ } } }, + "SingleValueResponseIncidentTimelineDto": { + "required": [ + "data" + ], + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/IncidentTimelineDto" + } + } + }, "SingleValueResponseInviteDto": { "required": [ "data" @@ -29195,6 +30226,17 @@ } } }, + "SingleValueResponsePolicySnapshotDto": { + "required": [ + "data" + ], + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/PolicySnapshotDto" + } + } + }, "SingleValueResponseResourceGroupDto": { "required": [ "data" @@ -30525,6 +31567,38 @@ } } }, + "TableValueResultIncidentStateTransitionDto": { + "required": [ + "data", + "hasNext", + "hasPrev" + ], + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/IncidentStateTransitionDto" + } + }, + "hasNext": { + "type": "boolean" + }, + "hasPrev": { + "type": "boolean" + }, + "totalElements": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "totalPages": { + "type": "integer", + "format": "int32", + "nullable": true + } + } + }, "TableValueResultIntegrationDto": { "required": [ "data", @@ -30845,6 +31919,38 @@ } } }, + "TableValueResultRuleEvaluationDto": { + "required": [ + "data", + "hasNext", + "hasPrev" + ], + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RuleEvaluationDto" + } + }, + "hasNext": { + "type": "boolean" + }, + "hasPrev": { + "type": "boolean" + }, + "totalElements": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "totalPages": { + "type": "integer", + "format": "int32", + "nullable": true + } + } + }, "TableValueResultScheduledMaintenanceDto": { "required": [ "data", diff --git a/src/devhelm/__init__.py b/src/devhelm/__init__.py index ab7b959..94afcac 100644 --- a/src/devhelm/__init__.py +++ b/src/devhelm/__init__.py @@ -19,6 +19,7 @@ from devhelm.resources.dependencies import Dependencies from devhelm.resources.deploy_lock import DeployLock from devhelm.resources.environments import Environments +from devhelm.resources.forensics import Forensics from devhelm.resources.incidents import Incidents from devhelm.resources.monitors import Monitors from devhelm.resources.notification_policies import NotificationPolicies @@ -42,6 +43,7 @@ AssertionSeverity, AssertionTestResultDto, CheckResultDto, + CheckTraceDto, ConfirmationPolicyType, CreateAlertChannelRequest, CreateApiKeyRequest, @@ -67,7 +69,9 @@ IncidentNewStatus, IncidentOldStatus, IncidentSeverity, + IncidentStateTransitionDto, IncidentStatus, + IncidentTimelineDto, IncidentUpdateCreatedBy, LinkedIncidentStatus, MembershipStatus, @@ -80,6 +84,7 @@ MonitorVersionDto, NotificationDispatchStatus, NotificationPolicyDto, + PolicySnapshotDto, PublishIncidentStatus, ReorderComponentsRequest, ReorderPageLayoutRequest, @@ -87,6 +92,7 @@ ResourceGroupDto, ResourceGroupHealthStatus, ResourceGroupMemberDto, + RuleEvaluationDto, SecretDto, ServiceSubscriptionDto, StatusPageBranding, @@ -148,6 +154,7 @@ # Resource classes "Monitors", "Incidents", + "Forensics", "AlertChannels", "NotificationPolicies", "Environments", @@ -173,6 +180,11 @@ "MonitorDto", "IncidentDto", "IncidentDetailDto", + "IncidentTimelineDto", + "IncidentStateTransitionDto", + "PolicySnapshotDto", + "RuleEvaluationDto", + "CheckTraceDto", "AlertChannelDto", "NotificationPolicyDto", "EnvironmentDto", diff --git a/src/devhelm/_generated.py b/src/devhelm/_generated.py index 13ceca0..128a493 100644 --- a/src/devhelm/_generated.py +++ b/src/devhelm/_generated.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: .openapi-preprocessed.json -# timestamp: 2026-04-24T17:23:19+00:00 +# timestamp: 2026-04-27T13:26:18+00:00 from __future__ import annotations from typing import Annotated, Any, Literal @@ -462,54 +462,6 @@ class AssertionTestResultDto(BaseModel): ] = None -class AuditEventDto(BaseModel): - model_config = ConfigDict(extra="forbid") - id: Annotated[int, Field(description="Unique audit event identifier")] - actor_id: Annotated[ - int | None, - Field( - alias="actorId", - description="User ID who performed the action; null for system actions", - ), - ] = None - actor_email: Annotated[ - str | None, - Field( - alias="actorEmail", - description="Email of the actor; null for system actions", - ), - ] = None - action: Annotated[ - str, - Field(description="Audit action type (e.g. monitor.created, api_key.revoked)"), - ] - resource_type: Annotated[ - str | None, - Field( - alias="resourceType", - description="Type of resource affected (e.g. monitor, api_key)", - ), - ] = None - resource_id: Annotated[ - str | None, Field(alias="resourceId", description="ID of the affected resource") - ] = None - resource_name: Annotated[ - str | None, - Field( - alias="resourceName", - description="Human-readable name of the affected resource", - ), - ] = None - metadata: Annotated[ - dict[str, dict[str, Any]] | None, - Field(description="Additional context about the action"), - ] = None - created_at: Annotated[ - AwareDatetime, - Field(alias="createdAt", description="Timestamp when the action was performed"), - ] - - class BasicAuthConfig(BaseModel): model_config = ConfigDict(extra="forbid") type: Literal["basic"] = "basic" @@ -2248,6 +2200,34 @@ class IncidentDto(BaseModel): description="Name of the resource group; populated on list responses. Omitted from JSON (undefined to SDKs) on detail responses, treat missing as null.", ), ] = None + triggering_check_id: Annotated[ + UUID | None, + Field( + alias="triggeringCheckId", + description="Scheduler-minted check execution ID whose result confirmed this incident; joins to check_results, rule_evaluations, and incident_state_transitions", + ), + ] = None + triggered_by_rule_snapshot_hash_hex: Annotated[ + str | None, + Field( + alias="triggeredByRuleSnapshotHashHex", + description="Hex SHA-256 of the canonical policy snapshot that fired; combined with triggeredByRuleIndex points to the exact TriggerRule", + ), + ] = None + triggered_by_rule_index: Annotated[ + int | None, + Field( + alias="triggeredByRuleIndex", + description="Index of the fired rule inside the policy's trigger_rules array", + ), + ] = None + engine_version: Annotated[ + str | None, + Field( + alias="engineVersion", + description="Detection engine semver that evaluated the rule", + ), + ] = None class IncidentFilterParams(BaseModel): @@ -2357,6 +2337,86 @@ class IncidentsSummaryDto(BaseModel): mttr30d: float | None = None +class IncidentStateTransitionDto(BaseModel): + model_config = ConfigDict(extra="forbid") + id: Annotated[UUID, Field(description="Forensic row UUID")] + occurred_at: Annotated[ + AwareDatetime, + Field(alias="occurredAt", description="When the state transition occurred"), + ] + monitor_id: Annotated[ + UUID, + Field(alias="monitorId", description="Monitor this transition pertains to"), + ] + incident_id: Annotated[ + UUID | None, + Field( + alias="incidentId", + description="Incident this transition belongs to; null for pre-incident (auto-cleared) transitions", + ), + ] = None + from_status: Annotated[ + str, + Field( + alias="fromStatus", + description="Previous status (WATCHING | TRIGGERED | CONFIRMED | RESOLVED)", + min_length=1, + ), + ] + to_status: Annotated[ + str, + Field( + alias="toStatus", + description="New status (WATCHING | TRIGGERED | CONFIRMED | RESOLVED)", + min_length=1, + ), + ] + reason: Annotated[ + str, + Field( + description="Why the transition fired (rule_matched | confirmation_met | auto_cleared_by_timeout | recovery_met | reopened | manually_resolved | policy_changed)", + min_length=1, + ), + ] + triggering_evaluation_ids: Annotated[ + list[UUID], + Field( + alias="triggeringEvaluationIds", + description="rule_evaluation ids that caused this transition (may be empty for timeout-driven edges)", + ), + ] + affected_regions: Annotated[ + list[str], + Field( + alias="affectedRegions", + description="Regions whose evaluations contributed to this transition", + ), + ] + policy_snapshot_hash_hex: Annotated[ + str, + Field( + alias="policySnapshotHashHex", + description="Hex-encoded hash of the policy snapshot that governed this transition", + min_length=1, + ), + ] + engine_version: Annotated[ + str, + Field( + alias="engineVersion", + description="Detection engine version that emitted this transition", + min_length=1, + ), + ] + check_id: Annotated[ + UUID, + Field( + alias="checkId", + description="Scheduler-minted check execution ID (V92) of the triggering result", + ), + ] + + class OldStatus(StrEnum): watching = "WATCHING" triggered = "TRIGGERED" @@ -2766,6 +2826,31 @@ class MemberDto(BaseModel): ] +class OldRole(StrEnum): + owner = "OWNER" + admin = "ADMIN" + member = "MEMBER" + + +class NewRole(StrEnum): + owner = "OWNER" + admin = "ADMIN" + member = "MEMBER" + + +class MemberRoleChangedMetadata(BaseModel): + model_config = ConfigDict(extra="forbid") + kind: Literal["member_role_changed"] = "member_role_changed" + old_role: Annotated[ + OldRole, + Field(alias="oldRole", description="Role the member held before the change"), + ] + new_role: Annotated[ + NewRole, + Field(alias="newRole", description="Role the member holds after the change"), + ] + + class Severity6(StrEnum): fail = "fail" warn = "warn" @@ -3135,6 +3220,44 @@ class PlanInfo(BaseModel): ] +class PolicySnapshotDto(BaseModel): + model_config = ConfigDict(extra="forbid") + hash_hex: Annotated[ + str, + Field( + alias="hashHex", + description="Hex-encoded SHA-256 of the canonical policy JSON", + min_length=1, + ), + ] + policy: Annotated[ + dict[str, dict[str, Any]], + Field(description="Canonical policy document (snake_case, sorted keys)"), + ] + engine_version: Annotated[ + str, + Field( + alias="engineVersion", + description="Detection engine version that observed this policy", + min_length=1, + ), + ] + first_seen_at: Annotated[ + AwareDatetime, + Field( + alias="firstSeenAt", + description="First time the detection engine evaluated against this policy bytes", + ), + ] + last_seen_at: Annotated[ + AwareDatetime, + Field( + alias="lastSeenAt", + description="Most recent time the engine evaluated against this policy bytes", + ), + ] + + class PollChartBucketDto(BaseModel): model_config = ConfigDict(extra="forbid") bucket: Annotated[ @@ -3618,6 +3741,99 @@ class RetryStrategy(BaseModel): ] +class RuleEvaluationDto(BaseModel): + model_config = ConfigDict(extra="forbid") + id: Annotated[UUID, Field(description="Forensic row UUID")] + occurred_at: Annotated[ + AwareDatetime, Field(alias="occurredAt", description="When the evaluation ran") + ] + monitor_id: Annotated[ + UUID, + Field( + alias="monitorId", + description="Monitor that produced the input check result", + ), + ] + region: Annotated[ + str, Field(description="Probe region of the input check result", min_length=1) + ] + policy_snapshot_hash_hex: Annotated[ + str, + Field( + alias="policySnapshotHashHex", + description="Hex-encoded hash of the policy snapshot this rule came from", + min_length=1, + ), + ] + rule_index: Annotated[ + int, + Field( + alias="ruleIndex", + description="Index into the policy's triggerRules array (0-based)", + ), + ] + rule_type: Annotated[ + str, + Field( + alias="ruleType", + description="Rule type (e.g. consecutive_failures, failures_in_window)", + min_length=1, + ), + ] + rule_scope: Annotated[ + str, + Field( + alias="ruleScope", + description="Rule scope (per_region | multi_region)", + min_length=1, + ), + ] + input_result_ids: Annotated[ + list[UUID], + Field( + alias="inputResultIds", + description="check_results IDs that were inputs to this evaluation (newest first)", + min_length=1, + ), + ] + output_matched: Annotated[ + bool, + Field( + alias="outputMatched", + description="Whether the rule fired on this evaluation", + ), + ] + evaluation_details: Annotated[ + dict[str, dict[str, Any]], + Field( + alias="evaluationDetails", + description="Structured details (e.g. failure counts, response-time aggregates)", + ), + ] + engine_version: Annotated[ + str, + Field( + alias="engineVersion", + description="Detection engine version that ran this evaluation", + min_length=1, + ), + ] + check_id: Annotated[ + UUID, + Field( + alias="checkId", + description="Scheduler-minted check execution ID (V92) — the causal chain identifier", + ), + ] + triggering_transition_id: Annotated[ + UUID | None, + Field( + alias="triggeringTransitionId", + description="If this evaluation caused a state transition, points to that transition's id", + ), + ] = None + + class ScheduledMaintenanceDto(BaseModel): model_config = ConfigDict(extra="forbid") id: Annotated[UUID, Field(description="Unique maintenance record identifier")] @@ -4215,6 +4431,11 @@ class SingleValueResponseOrganizationDto(BaseModel): data: OrganizationDto +class SingleValueResponsePolicySnapshotDto(BaseModel): + model_config = ConfigDict(extra="forbid") + data: PolicySnapshotDto + + class SingleValueResponseResourceGroupHealthDto(BaseModel): model_config = ConfigDict(extra="forbid") data: ResourceGroupHealthDto @@ -4626,15 +4847,6 @@ class TableValueResultApiKeyDto(BaseModel): total_pages: Annotated[int | None, Field(alias="totalPages")] = None -class TableValueResultAuditEventDto(BaseModel): - model_config = ConfigDict(extra="forbid") - data: list[AuditEventDto] - has_next: Annotated[bool, Field(alias="hasNext")] - has_prev: Annotated[bool, Field(alias="hasPrev")] - total_elements: Annotated[int | None, Field(alias="totalElements")] = None - total_pages: Annotated[int | None, Field(alias="totalPages")] = None - - class TableValueResultCategoryDto(BaseModel): model_config = ConfigDict(extra="forbid") data: list[CategoryDto] @@ -4671,6 +4883,15 @@ class TableValueResultIncidentDto(BaseModel): total_pages: Annotated[int | None, Field(alias="totalPages")] = None +class TableValueResultIncidentStateTransitionDto(BaseModel): + model_config = ConfigDict(extra="forbid") + data: list[IncidentStateTransitionDto] + has_next: Annotated[bool, Field(alias="hasNext")] + has_prev: Annotated[bool, Field(alias="hasPrev")] + total_elements: Annotated[int | None, Field(alias="totalElements")] = None + total_pages: Annotated[int | None, Field(alias="totalPages")] = None + + class TableValueResultInviteDto(BaseModel): model_config = ConfigDict(extra="forbid") data: list[InviteDto] @@ -4716,6 +4937,15 @@ class TableValueResultNotificationDto(BaseModel): total_pages: Annotated[int | None, Field(alias="totalPages")] = None +class TableValueResultRuleEvaluationDto(BaseModel): + model_config = ConfigDict(extra="forbid") + data: list[RuleEvaluationDto] + has_next: Annotated[bool, Field(alias="hasNext")] + has_prev: Annotated[bool, Field(alias="hasPrev")] + total_elements: Annotated[int | None, Field(alias="totalElements")] = None + total_pages: Annotated[int | None, Field(alias="totalPages")] = None + + class TableValueResultScheduledMaintenanceDto(BaseModel): model_config = ConfigDict(extra="forbid") data: list[ScheduledMaintenanceDto] @@ -5913,6 +6143,16 @@ class AddMonitorTagsRequest(BaseModel): ] = None +class AuditMetadata(RootModel[MemberRoleChangedMetadata | None]): + root: Annotated[ + MemberRoleChangedMetadata | None, + Field( + description="Typed metadata payload attached to an audit event; null for actions that carry no extra context.", + discriminator="kind", + ), + ] = None + + class AuthMeResponse(BaseModel): model_config = ConfigDict(extra="forbid") key: KeyInfo @@ -5965,6 +6205,29 @@ class BulkMonitorActionResult(BaseModel): ] +class CheckTraceDto(BaseModel): + model_config = ConfigDict(extra="forbid") + check_id: Annotated[ + UUID, + Field( + alias="checkId", description="The check execution ID this trace is keyed by" + ), + ] + evaluations: Annotated[ + list[RuleEvaluationDto], + Field(description="All rule evaluations that ran for this check"), + ] + transitions: Annotated[ + list[IncidentStateTransitionDto], + Field( + description="State-machine transitions this check caused (may be empty if nothing fired)" + ), + ] + policy_snapshot: Annotated[ + PolicySnapshotDto | None, Field(alias="policySnapshot") + ] = None + + class ComponentUptimeDayDto(BaseModel): model_config = ConfigDict(extra="forbid") date: Annotated[ @@ -6473,6 +6736,24 @@ class IncidentPolicyDto(BaseModel): ] = None +class IncidentTimelineDto(BaseModel): + model_config = ConfigDict(extra="forbid") + transitions: Annotated[ + list[IncidentStateTransitionDto], + Field(description="State-machine transitions in chronological order"), + ] + triggering_evaluations: Annotated[ + list[RuleEvaluationDto], + Field( + alias="triggeringEvaluations", + description="Rule evaluations that caused any of the transitions above. Correlate via evaluation.triggeringTransitionId == transition.id", + ), + ] + policy_snapshot: Annotated[ + PolicySnapshotDto | None, Field(alias="policySnapshot") + ] = None + + class IntegrationConfigSchemaDto(BaseModel): model_config = ConfigDict(extra="forbid") connection_fields: Annotated[ @@ -6899,6 +7180,11 @@ class SingleValueResponseBulkMonitorActionResult(BaseModel): data: BulkMonitorActionResult +class SingleValueResponseCheckTraceDto(BaseModel): + model_config = ConfigDict(extra="forbid") + data: CheckTraceDto + + class SingleValueResponseDashboardOverviewDto(BaseModel): model_config = ConfigDict(extra="forbid") data: DashboardOverviewDto @@ -6919,6 +7205,11 @@ class SingleValueResponseIncidentPolicyDto(BaseModel): data: IncidentPolicyDto +class SingleValueResponseIncidentTimelineDto(BaseModel): + model_config = ConfigDict(extra="forbid") + data: IncidentTimelineDto + + class SingleValueResponseMonitorAssertionDto(BaseModel): model_config = ConfigDict(extra="forbid") data: MonitorAssertionDto @@ -7291,6 +7582,51 @@ class UpdateNotificationPolicyRequest(BaseModel): ] = None +class AuditEventDto(BaseModel): + model_config = ConfigDict(extra="forbid") + id: Annotated[int, Field(description="Unique audit event identifier")] + actor_id: Annotated[ + int | None, + Field( + alias="actorId", + description="User ID who performed the action; null for system actions", + ), + ] = None + actor_email: Annotated[ + str | None, + Field( + alias="actorEmail", + description="Email of the actor; null for system actions", + ), + ] = None + action: Annotated[ + str, + Field(description="Audit action type (e.g. monitor.created, api_key.revoked)"), + ] + resource_type: Annotated[ + str | None, + Field( + alias="resourceType", + description="Type of resource affected (e.g. monitor, api_key)", + ), + ] = None + resource_id: Annotated[ + str | None, Field(alias="resourceId", description="ID of the affected resource") + ] = None + resource_name: Annotated[ + str | None, + Field( + alias="resourceName", + description="Human-readable name of the affected resource", + ), + ] = None + metadata: AuditMetadata | None = None + created_at: Annotated[ + AwareDatetime, + Field(alias="createdAt", description="Timestamp when the action was performed"), + ] + + class BatchComponentUptimeDto(BaseModel): model_config = ConfigDict(extra="forbid") components: Annotated[ @@ -7350,6 +7686,15 @@ class SingleValueResponseStatusPageIncidentDto(BaseModel): data: StatusPageIncidentDto +class TableValueResultAuditEventDto(BaseModel): + model_config = ConfigDict(extra="forbid") + data: list[AuditEventDto] + has_next: Annotated[bool, Field(alias="hasNext")] + has_prev: Annotated[bool, Field(alias="hasPrev")] + total_elements: Annotated[int | None, Field(alias="totalElements")] = None + total_pages: Annotated[int | None, Field(alias="totalPages")] = None + + class CheckResultDetailsDto(BaseModel): model_config = ConfigDict(extra="forbid") status_code: Annotated[ diff --git a/src/devhelm/client.py b/src/devhelm/client.py index 407e141..75bb44b 100644 --- a/src/devhelm/client.py +++ b/src/devhelm/client.py @@ -8,6 +8,7 @@ from devhelm.resources.dependencies import Dependencies from devhelm.resources.deploy_lock import DeployLock from devhelm.resources.environments import Environments +from devhelm.resources.forensics import Forensics from devhelm.resources.incidents import Incidents from devhelm.resources.monitors import Monitors from devhelm.resources.notification_policies import NotificationPolicies @@ -39,6 +40,7 @@ class Devhelm: monitors: Monitors incidents: Incidents + forensics: Forensics alert_channels: AlertChannels notification_policies: NotificationPolicies environments: Environments @@ -72,6 +74,7 @@ def __init__( self.monitors = Monitors(client) self.incidents = Incidents(client) + self.forensics = Forensics(client) self.alert_channels = AlertChannels(client) self.notification_policies = NotificationPolicies(client) self.environments = Environments(client) diff --git a/src/devhelm/resources/forensics.py b/src/devhelm/resources/forensics.py new file mode 100644 index 0000000..60dde5a --- /dev/null +++ b/src/devhelm/resources/forensics.py @@ -0,0 +1,146 @@ +"""Forensic read-only endpoints: incident timelines, check traces, policy +snapshots, rule evaluations, and state transitions. + +These endpoints expose the immutable event-sourced forensic model described +in ``cowork/design/046-detection-forensic-model.md``. Use them to audit how +the detection engine arrived at a given state change, replay an incident, +or inspect the exact policy that fired a rule. +""" + +from __future__ import annotations + +from datetime import datetime +from typing import TypeVar + +import httpx +from pydantic import BaseModel + +from devhelm._generated import ( + CheckTraceDto, + IncidentStateTransitionDto, + IncidentTimelineDto, + PolicySnapshotDto, + RuleEvaluationDto, +) +from devhelm._http import api_get, path_param +from devhelm._pagination import Page, _validate_page +from devhelm._validation import parse_list, parse_single + +M = TypeVar("M", bound=BaseModel) + +# Explicit primitive-only param dict avoids mypy's ``disallow_any_explicit`` +# in strict mode while still accepting the shapes httpx serialises for us. +_ParamValue = str | int | bool | None +_ParamDict = dict[str, _ParamValue] + + +def _format_instant(value: datetime | str | None) -> str | None: + if value is None: + return None + if isinstance(value, datetime): + return value.isoformat() + return value + + +class Forensics: + """Read-only forensic endpoints for detection audit trails.""" + + def __init__(self, client: httpx.Client) -> None: + self._client = client + + def incident_timeline(self, id: int | str) -> IncidentTimelineDto: + """Full reconstructed timeline for an incident: state transitions, + triggering rule evaluations, and the active policy snapshot. + """ + return parse_single( + IncidentTimelineDto, + api_get( + self._client, f"/api/v1/forensics/incidents/{path_param(id)}/timeline" + ), + f"GET /api/v1/forensics/incidents/{id}/timeline", + ) + + def check_trace(self, check_id: str) -> CheckTraceDto: + """Everything recorded for a single check execution: rule + evaluations, state transitions, and the policy snapshot in effect. + """ + return parse_single( + CheckTraceDto, + api_get(self._client, f"/api/v1/forensics/traces/{path_param(check_id)}"), + f"GET /api/v1/forensics/traces/{check_id}", + ) + + def policy_snapshot(self, hash_hex: str) -> PolicySnapshotDto: + """Fetch a policy snapshot by its content-addressed SHA-256 hash.""" + return parse_single( + PolicySnapshotDto, + api_get( + self._client, + f"/api/v1/forensics/policy-snapshots/{path_param(hash_hex)}", + ), + f"GET /api/v1/forensics/policy-snapshots/{hash_hex}", + ) + + def monitor_rule_evaluations( + self, + monitor_id: int | str, + *, + rule_type: str | None = None, + region: str | None = None, + only_matched: bool | None = None, + from_: datetime | str | None = None, + to: datetime | str | None = None, + page: int = 0, + size: int = 50, + ) -> Page[RuleEvaluationDto]: + """List rule evaluations produced for a monitor (paginated).""" + params: _ParamDict = {"page": page, "size": size} + if rule_type is not None: + params["ruleType"] = rule_type + if region is not None: + params["region"] = region + if only_matched is not None: + params["onlyMatched"] = only_matched + if from_ is not None: + params["from"] = _format_instant(from_) + if to is not None: + params["to"] = _format_instant(to) + + path = f"/api/v1/forensics/monitors/{path_param(monitor_id)}/rule-evaluations" + return self._fetch_table_page(path, RuleEvaluationDto, params) + + def monitor_transitions( + self, + monitor_id: int | str, + *, + from_: datetime | str | None = None, + to: datetime | str | None = None, + page: int = 0, + size: int = 50, + ) -> Page[IncidentStateTransitionDto]: + """List state transitions recorded for a monitor (paginated).""" + params: _ParamDict = {"page": page, "size": size} + if from_ is not None: + params["from"] = _format_instant(from_) + if to is not None: + params["to"] = _format_instant(to) + + path = f"/api/v1/forensics/monitors/{path_param(monitor_id)}/transitions" + return self._fetch_table_page(path, IncidentStateTransitionDto, params) + + def _fetch_table_page( + self, path: str, model_class: type[M], params: _ParamDict + ) -> Page[M]: + """Forensic monitor endpoints return the offset-paged envelope + ``{data, hasNext, hasPrev, totalElements, totalPages}``; reuse the + shared validator but forward filter params alongside page/size. + """ + resp = api_get(self._client, path, params=params) + envelope = _validate_page(resp) + return Page( + data=parse_list(model_class, envelope.data, f"GET {path}"), + has_next=envelope.hasNext, + has_prev=envelope.hasPrev, + total_elements=envelope.totalElements, + total_pages=envelope.totalPages, + ) diff --git a/src/devhelm/types.py b/src/devhelm/types.py index 8206ce7..0fe2f69 100644 --- a/src/devhelm/types.py +++ b/src/devhelm/types.py @@ -46,6 +46,7 @@ ChangedVia, ChannelType, CheckResultDto, # noqa: F401 + CheckTraceDto, CompletionReason, CreateAlertChannelRequest, CreateApiKeyRequest, @@ -71,6 +72,8 @@ IncidentDetailDto, IncidentDto, IncidentMode, + IncidentStateTransitionDto, + IncidentTimelineDto, ManagedBy, Method, MonitorDto, @@ -78,6 +81,7 @@ NotificationPolicyDto, Operator, OrgRole, + PolicySnapshotDto, PublishStatusPageIncidentRequest, RecordType, ReorderComponentsRequest, @@ -87,6 +91,7 @@ ResourceGroupDto, ResourceGroupMemberDto, # noqa: F401 RoleOffered, + RuleEvaluationDto, Scope, SecretDto, ServiceSubscriptionDto, @@ -226,6 +231,7 @@ "ApiKeyDto", "AssertionTestResultDto", "CheckResultDto", + "CheckTraceDto", "CreateAlertChannelRequest", "CreateApiKeyRequest", "CreateEnvironmentRequest", @@ -246,15 +252,19 @@ "EnvironmentDto", "IncidentDetailDto", "IncidentDto", + "IncidentStateTransitionDto", + "IncidentTimelineDto", "MonitorDto", "MonitorVersionDto", "NotificationPolicyDto", + "PolicySnapshotDto", "PublishStatusPageIncidentRequest", "ReorderComponentsRequest", "ReorderPageLayoutRequest", "ResolveIncidentRequest", "ResourceGroupDto", "ResourceGroupMemberDto", + "RuleEvaluationDto", "SecretDto", "ServiceSubscriptionDto", "StatusPageBranding", diff --git a/tests/test_client.py b/tests/test_client.py index a84c662..7e00a36 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -10,6 +10,7 @@ from devhelm.resources.dependencies import Dependencies from devhelm.resources.deploy_lock import DeployLock from devhelm.resources.environments import Environments +from devhelm.resources.forensics import Forensics from devhelm.resources.incidents import Incidents from devhelm.resources.monitors import Monitors from devhelm.resources.notification_policies import NotificationPolicies @@ -36,6 +37,14 @@ def test_monitors(self, client: Devhelm) -> None: def test_incidents(self, client: Devhelm) -> None: assert isinstance(client.incidents, Incidents) + def test_forensics(self, client: Devhelm) -> None: + assert isinstance(client.forensics, Forensics) + assert callable(client.forensics.incident_timeline) + assert callable(client.forensics.check_trace) + assert callable(client.forensics.policy_snapshot) + assert callable(client.forensics.monitor_rule_evaluations) + assert callable(client.forensics.monitor_transitions) + def test_alert_channels(self, client: Devhelm) -> None: assert isinstance(client.alert_channels, AlertChannels) diff --git a/uv.lock b/uv.lock index 437d105..f5bbde5 100644 --- a/uv.lock +++ b/uv.lock @@ -331,7 +331,7 @@ wheels = [ [[package]] name = "devhelm" -version = "0.3.0" +version = "0.4.0" source = { editable = "." } dependencies = [ { name = "httpx" },