Skip to content
Open
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
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 19 additions & 0 deletions proto/sentry_protos/billing/v1/common/v1/seat_status.proto
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
syntax = "proto3";

package sentry_protos.billing.v1.common.v1;

// Lifecycle status of a billing seat. Shared by both the seat_activity and
// seats services.
enum SeatStatus {
SEAT_STATUS_UNSPECIFIED = 0;
// Seat is active and counting toward the billing period.
SEAT_STATUS_ACTIVE = 1;
// Seat is disabled; it still counts toward the period total but new
// activity (e.g. check-ins) is dropped.
SEAT_STATUS_DISABLED = 2;
// Seat was created but exceeds reserved+PAYG capacity. Treated as
// DISABLED by external consumers (the distinction is internal only).
SEAT_STATUS_OVER_QUOTA = 3;
// Seat does not exist (removed or never created).
SEAT_STATUS_DNE = 4;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
syntax = "proto3";

package sentry_protos.billing.v1.services.seat_activity.v1;

// Marks a contract's seat tracker as CLOSED, preventing further seat actions.
// Called during rollover before writing seats to the new contract.
// Silently succeeds if no tracker exists for the contract.
message CloseContractRequest {
uint64 contract_id = 1;
}

message CloseContractResponse {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
syntax = "proto3";

package sentry_protos.billing.v1.services.seat_activity.v1;

import "sentry_protos/billing/v1/seat_category.proto";
import "sentry_protos/billing/v1/services/seat_activity/v1/seat_activity.proto";

// Returns each seat's earliest-ever recorded action timestamp across the
// entire organization (not scoped to a single contract). Used during contract
// rollover to sort seats by seniority when capacity is tight.
message GetOriginalSeatDatesRequest {
uint64 organization_id = 1;
sentry_protos.billing.v1.SeatCategory seat_category = 2;
repeated string external_product_identifiers = 3;
}

message GetOriginalSeatDatesResponse {
repeated OriginalSeatDate original_seat_dates = 1;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
syntax = "proto3";

package sentry_protos.billing.v1.services.seat_activity.v1;

import "google/protobuf/timestamp.proto";
import "sentry_protos/billing/v1/services/seat_activity/v1/seat_activity.proto";

// Returns every seat action recorded for a contract, grouped by product
// identifier and ordered chronologically. This is the raw audit log of all
// state transitions during a billing period.
message GetSeatActivityHistoryRequest {
uint64 contract_id = 1;
}

message GetSeatActivityHistoryResponse {
repeated ExternalProductSeatActivity seat_activities = 1;
google.protobuf.Timestamp read_ts = 2;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
syntax = "proto3";

package sentry_protos.billing.v1.services.seat_activity.v1;

// Checks whether a contract has a seat tracking record and whether it is
// ACTIVE. Used by the seats service to decide whether to bootstrap a new
// tracker or trigger a rollover from a previous contract.
message GetSeatContractStateRequest {
uint64 contract_id = 1;
}

message GetSeatContractStateResponse {
bool exists = 1;
bool is_active = 2;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
syntax = "proto3";

package sentry_protos.billing.v1.services.seat_activity.v1;

import "sentry_protos/billing/v1/services/seat_activity/v1/seat_activity.proto";

// Returns the current status of a single seat by replaying its action history
// on the given contract (last action wins). Returns DNE if no actions exist.
message GetSeatStateRequest {
uint64 contract_id = 1;
SeatObject seat_object = 2;
}

message GetSeatStateResponse {
SeatState seat_state = 1;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
syntax = "proto3";

package sentry_protos.billing.v1.services.seat_activity.v1;

import "sentry_protos/billing/v1/services/seat_activity/v1/seat_activity.proto";

// The single write path for all seat mutations. Appends action records in bulk
// under a transaction. Does NOT check budgets or eligibility — that is the
// caller's responsibility.
//
// Rejects duplicate identifiers within a single request. Requires an ACTIVE
// contract tracker unless create_new_contract_state is set.
message PerformSeatActionsRequest {
uint64 contract_id = 1;
repeated SeatActionItem seat_actions = 2;
// When true, creates a new ACTIVE contract tracker for this contract.
// Fails if one already exists (used for first-time setup and rollover).
bool create_new_contract_state = 3;
}

message PerformSeatActionsResponse {
repeated SeatState current_seat_states = 1;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
syntax = "proto3";

package sentry_protos.billing.v1.services.seat_activity.v1;

import "google/protobuf/timestamp.proto";
import "sentry_protos/billing/v1/common/v1/seat_status.proto";
import "sentry_protos/billing/v1/seat_category.proto";

// Identifies a billable seat (e.g. a monitor, uptime detector, or seer
// contributor) within an organization and project.
message SeatObject {
uint64 organization_id = 1;
uint64 project_id = 2;
sentry_protos.billing.v1.SeatCategory seat_category = 3;
// Human-readable label for the seat (e.g. monitor name).
string external_product_display_name = 4;
// Unique identifier for the seat within its category and project
// (e.g. monitor slug). Used as the key for all state lookups.
string external_product_identifier = 5;
}

// Reconstructed point-in-time state for a single seat, derived by replaying
// its action history (last action wins).
message SeatState {
string external_product_identifier = 1;
sentry_protos.billing.v1.common.v1.SeatStatus current_status = 2;
// Timestamp of the first-ever action for this seat on the contract.
optional google.protobuf.Timestamp created_ts = 3;
// Timestamp of the most recent action for this seat on the contract.
optional google.protobuf.Timestamp last_action_ts = 4;
// When the state was read (server clock). Useful for cache staleness checks.
google.protobuf.Timestamp read_ts = 5;
}

// A single immutable record of a seat state transition, stored as an
// append-only audit log.
message SeatActionRecord {
uint64 contract_id = 1;
uint64 organization_id = 2;
uint64 project_id = 3;
google.protobuf.Timestamp timestamp = 4;
sentry_protos.billing.v1.common.v1.SeatStatus status = 5;
uint32 seat_category = 6;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Seat category uses raw integer type

Medium Severity

SeatActionRecord.seat_category is a uint32 while every other seat-category field in this change uses the SeatCategory enum, including SeatObject in the same file. Callers get a raw number instead of a typed value, and changing the field later is a breaking generated-code change.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 092a501. Configure here.

string external_product_identifier = 7;
string external_product_display_name = 8;
}

// All action records for a single external product identifier on a contract,
// ordered chronologically.
message ExternalProductSeatActivity {
string external_product_identifier = 1;
repeated SeatActionRecord seat_actions = 2;
}

// Input item for PerformSeatActions: pairs a seat with its desired new state.
message SeatActionItem {
SeatObject seat_object = 1;
Comment on lines +54 to +57

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug: The SeatActionItem message incorrectly uses the SeatState read-model for input, creating ambiguity with a duplicated external_product_identifier and requiring clients to send meaningless server-generated fields.
Severity: MEDIUM

Suggested Fix

Refactor SeatActionItem to not embed the SeatState read-model. Instead, it should accept only the necessary input fields directly, such as the seat_object and the desired current_status. This removes the ambiguity of the duplicate identifier and eliminates the need for clients to send meaningless server-generated timestamp fields.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location:
proto/sentry_protos/billing/v1/services/seat_activity/v1/seat_activity.proto#L54-L57

Potential issue: The `SeatActionItem` message incorrectly uses `SeatState`, a
read-model, as an input for a write operation. This forces clients to provide
meaningless server-generated values like `read_ts`. More critically, it duplicates the
`external_product_identifier` field, which is also present in the `seat_object` field.
The schema provides no guidance on how to resolve conflicts if these two identifiers
differ, creating ambiguity for the server implementation and potential for subtle bugs.

Did we get this right? 👍 / 👎 to inform future reviews.

SeatState seat_state = 2;
}

// The earliest-ever recorded action timestamp for a seat across all contracts
// in an organization. Used to sort seats by seniority during rollover.
message OriginalSeatDate {
string external_product_identifier = 1;
google.protobuf.Timestamp created_ts = 2;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
syntax = "proto3";

package sentry_protos.billing.v1.services.seats.v1;

import "sentry_protos/billing/v1/services/seat_activity/v1/seat_activity.proto";
import "sentry_protos/billing/v1/services/seats/v1/seats.proto";

// Assigns a single billing seat for the current contract period. Internally
// runs check_assign_seats to determine capacity: ACCEPTED seats become ACTIVE,
// RATE_LIMITED seats become OVER_QUOTA, INVALID seats are rejected.
// Lazily initializes the contract's seat tracker on first write.
message AssignSeatRequest {
sentry_protos.billing.v1.services.seat_activity.v1.SeatObject seat_object = 1;
}

message AssignSeatResponse {
SeatAssignmentOutcome outcome = 1;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
syntax = "proto3";

package sentry_protos.billing.v1.services.seats.v1;

import "sentry_protos/billing/v1/services/seat_activity/v1/seat_activity.proto";
import "sentry_protos/billing/v1/services/seats/v1/seats.proto";

// Dry-run check: can these seats be assigned? Validates the product is enabled
// on the plan, filters out already-existing seats, then simulates usage pricing
// to determine if reserved or PAYG budgets have capacity.
// All seat_objects must share the same seat category and organization.
message CheckAssignSeatsRequest {
repeated sentry_protos.billing.v1.services.seat_activity.v1.SeatObject seat_objects = 1;
// When omitted, resolves to the org's current contract.
optional uint64 contract_id = 2;
}

message CheckAssignSeatsResponse {
SeatAssignmentOutcome outcome = 1;
string reason = 2;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
syntax = "proto3";

package sentry_protos.billing.v1.services.seats.v1;

import "sentry_protos/billing/v1/services/seat_activity/v1/seat_activity.proto";

// Sets a seat to DISABLED status. The seat still counts toward the period
// total but stops accepting new activity (e.g. check-ins are dropped).
// Only allowed for seat categories whose policy supports disabling.
message DisableSeatRequest {
sentry_protos.billing.v1.services.seat_activity.v1.SeatObject seat_object = 1;
// When omitted, resolves to the org's current contract.
optional uint64 contract_id = 2;
}

message DisableSeatResponse {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
syntax = "proto3";

package sentry_protos.billing.v1.services.seats.v1;

import "sentry_protos/billing/v1/services/usage/v1/endpoint_usage.proto";

// Response-only proto; the request reuses GetUsageRequest from the usage service.
// Returns per-day billable and active seat counts for the requested period.
message GetDailySeatUsageResponse {
repeated sentry_protos.billing.v1.services.usage.v1.DailySeatUsage seat_days = 1;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
syntax = "proto3";

package sentry_protos.billing.v1.services.seats.v1;

import "sentry_protos/billing/v1/services/seats/v1/seats.proto";

// Response-only proto; the request reuses GetUsageByProjectRequest from the
// usage service. Same as GetDailySeatUsageResponse but broken out per project.
message GetDailySeatUsageByProjectResponse {
repeated ProjectSeatUsage projects = 1;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
syntax = "proto3";

package sentry_protos.billing.v1.services.seats.v1;

import "sentry_protos/billing/v1/common/v1/seat_status.proto";
import "sentry_protos/billing/v1/services/seat_activity/v1/seat_activity.proto";

// High-throughput read path called by the monitor consumer on every check-in.
// Looks up the seat's current state and maps it to an external-facing status.
// OVER_QUOTA is mapped to DISABLED — external consumers do not distinguish
// between the two.
message GetSeatStatusForExternalProductRequest {
sentry_protos.billing.v1.services.seat_activity.v1.SeatObject seat_object = 1;
}

message GetSeatStatusForExternalProductResponse {
sentry_protos.billing.v1.common.v1.SeatStatus status = 1;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
syntax = "proto3";

package sentry_protos.billing.v1.services.seats.v1;

import "sentry_protos/billing/v1/services/seat_activity/v1/seat_activity.proto";

// Permanently removes a seat (status -> DNE), freeing the slot for
// reallocation. Only allowed for seat categories whose policy supports removal.
message RemoveSeatRequest {
sentry_protos.billing.v1.services.seat_activity.v1.SeatObject seat_object = 1;
// When omitted, resolves to the org's current contract.
optional uint64 contract_id = 2;
}

message RemoveSeatResponse {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
syntax = "proto3";

package sentry_protos.billing.v1.services.seats.v1;

// Recreates carry-over seats when a contract rolls over. Closes the old
// contract's tracker, collects seats that ended ACTIVE or OVER_QUOTA, orders
// them by seniority, and splits against the new contract's capacity (fits ->
// ACTIVE, overflow -> OVER_QUOTA). No-ops if the new contract already has
// seat state (idempotent on retry).
message RolloverSeatsToNewContractRequest {
uint64 prev_contract_id = 1;
uint64 new_contract_id = 2;
}

message RolloverSeatsToNewContractResponse {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
syntax = "proto3";

package sentry_protos.billing.v1.services.seats.v1;

import "sentry_protos/billing/v1/services/seat_activity/v1/seat_activity.proto";

// Atomically renames a seat's identifier (e.g. when a monitor slug changes).
// Implemented as two actions in one batch: old identifier -> DNE, new
// identifier -> previous status. Only allowed for seat categories whose
// policy supports renaming.
message UpdateSeatIdentifierRequest {
sentry_protos.billing.v1.services.seat_activity.v1.SeatObject seat_object = 1;
string new_identifier = 2;
// When omitted, resolves to the org's current contract.
optional uint64 contract_id = 3;
}

message UpdateSeatIdentifierResponse {}
22 changes: 22 additions & 0 deletions proto/sentry_protos/billing/v1/services/seats/v1/seats.proto
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
syntax = "proto3";

package sentry_protos.billing.v1.services.seats.v1;

import "sentry_protos/billing/v1/services/usage/v1/endpoint_usage.proto";

// Result of a seat assignment or check_assign_seats call.
enum SeatAssignmentOutcome {
SEAT_ASSIGNMENT_OUTCOME_UNSPECIFIED = 0;
// Seat fits within reserved or PAYG capacity.
SEAT_ASSIGNMENT_OUTCOME_ACCEPTED = 1;
// No budget remaining; seat is created as OVER_QUOTA on assign.
SEAT_ASSIGNMENT_OUTCOME_RATE_LIMITED = 2;
// Product not enabled on plan or request is structurally invalid.
SEAT_ASSIGNMENT_OUTCOME_INVALID = 3;
}

// Per-project seat usage over a date range, used by get_daily_seat_usage_by_project.
message ProjectSeatUsage {
uint64 project_id = 1;
repeated sentry_protos.billing.v1.services.usage.v1.DailySeatUsage seat_days = 2;
}
10 changes: 10 additions & 0 deletions rust/src/_include.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,16 @@ pub mod sentry_protos {
include!("sentry_protos.billing.v1.services.rate_card.v1.rs");
}
}
pub mod seat_activity {
pub mod v1 {
include!("sentry_protos.billing.v1.services.seat_activity.v1.rs");
}
}
pub mod seats {
pub mod v1 {
include!("sentry_protos.billing.v1.services.seats.v1.rs");
}
}
pub mod trial {
pub mod v1 {
include!("sentry_protos.billing.v1.services.trial.v1.rs");
Expand Down
Loading
Loading