Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
6 changes: 6 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,12 @@ test-integration: build setup-venv ## run integration tests
.venv/bin/pytest tests -n $(PYTEST_N) -v
.PHONY: test-integration

PYTEST_N ?= auto
TEST ?= tests
test-custom: build setup-venv ## run arbitrary tests
.venv/bin/pytest -n $(PYTEST_N) $(TEST) -vv
.PHONY: test-custom
Comment thread
tobias-wilfert marked this conversation as resolved.
Outdated

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Changes to build systems etc. ideally are split out into separate PRs.

Easier to review, also (which I don't expect to be a problem here), if we have to revert the PR it doesn't also revert these changes.


# Documentation

doc: doc-rust ## generate all API docs
Expand Down
4 changes: 4 additions & 0 deletions relay-dynamic-config/src/feature.rs
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,10 @@ pub enum Feature {
#[serde(rename = "organizations:standalone-span-ingestion")]
DeprecatedStandaloneSpanIngestion,

/// Enable relay billing outcome generation.
#[serde(rename = "organizations:relay-generate-billing-outcome")]
GenerateBillingOutcome,

@tobias-wilfert tobias-wilfert Jun 10, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think we would want this just under MinidumpUploads, since now it is in the Deprecated* group.

Also must admit that I am not sure if there is a functional difference these days between "organizations:... and "projects:...

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Yep, ideally it's not in the deprecated group.

these days between "organizations:... and "projects:...

You can also roll out a projects: flag to all projects in an org, but you can't roll out a organizations: flag to a single project. Either is fine here.


/// Forward compatibility.
#[doc(hidden)]
#[serde(other)]
Expand Down
37 changes: 37 additions & 0 deletions relay-server/src/metrics/outcomes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,43 @@ impl MetricOutcomes {
}
}
}

/// Tracks billing-related outcomes for the list of buckets, adding the
/// "billing_outcome_accepted" tag to the bucket if that bucket is accepted.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
/// Tracks billing-related outcomes for the list of buckets, adding the
/// "billing_outcome_accepted" tag to the bucket if that bucket is accepted.
/// Emits accepted outcomes for the provided list of buckets.
///
/// Additionally adds a marker tag `billing_outcome_accepted` to all buckets for which an outcome
/// has been emitted.

Nit: billing-related would also include filtered outcomes (see more is_billing on TrackRawOutcome).

pub fn track_billing_outcome(&self, scoping: Scoping, buckets: &mut [Bucket]) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Is this intentionally only implemented for spans?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

yes, since this only serves the billing outcomes path.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I assume you opted for this additional method because we need to temporarily add the tag to prevent double counting and longterm we can merge this into track?

The comment in track is now also invalid with that change, not a big deal as long as it eventually gets fixed:

        // Never emit accepted outcomes for surrogate metrics.
        // These are handled from within Sentry.
        if !matches!(outcome, Outcome::Accepted) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

It felt right to keep them separate given the divergence in logic, at least for now. Comment updated.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
pub fn track_billing_outcome(&self, scoping: Scoping, buckets: &mut [Bucket]) {
pub fn track_accepted_outcomes(&self, scoping: Scoping, buckets: &mut [Bucket]) {

let timestamp = Utc::now();
for bucket in buckets {
let summary = bucket.summary();
match summary {
BucketSummary::Spans {
count,
is_segment,
was_transaction: _,
} if count > 0 => {
let all_categories = [DataCategory::Span, DataCategory::Transaction];
let num_categories = if is_segment { 2 } else { 1 };
let categories = &all_categories[0..num_categories];

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
let all_categories = [DataCategory::Span, DataCategory::Transaction];
let num_categories = if is_segment { 2 } else { 1 };
let categories = &all_categories[0..num_categories];
match is_segment {
true => &[DataCategory::Span, DataCategory::Transaction],
false => &[DataCategory::Span],
}


bucket
.tags
.insert("billing_outcome_accepted".to_owned(), "true".to_owned());

for category in categories {
self.outcomes.send(TrackOutcome {
timestamp,
scoping,
outcome: Outcome::Accepted,
event_id: None,
remote_addr: None,
category: *category,
quantity: count as u32,
});
}
}
_ => continue,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I'd exhaustively match on BucketSummary so there is a compiler error once a new variant is added. This unfortunately means though you need to move the if into the match body.

};
}
}
}

/// The return value of [`TrackableBucket::summary`].
Expand Down
15 changes: 14 additions & 1 deletion relay-server/src/services/processor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ use relay_base_schema::project::{ProjectId, ProjectKey};
use relay_cogs::{AppFeature, Cogs, FeatureWeights, ResourceId, Token};
use relay_common::time::UnixTimestamp;
use relay_config::{Config, HttpEncoding, UpstreamDescriptor};
use relay_dynamic_config::Feature;
use relay_event_normalization::{ClockDriftProcessor, GeoIpLookup};
use relay_event_schema::processor::ProcessingAction;
use relay_event_schema::protocol::ClientReport;
Expand Down Expand Up @@ -1171,6 +1172,7 @@ impl EnvelopeProcessorService {
///
/// This function runs the following steps:
/// - rate limiting
/// - emit billing outcomes
/// - submit to `StoreForwarder`
#[cfg(feature = "processing")]
async fn encode_metrics_processing(
Expand All @@ -1188,14 +1190,25 @@ impl EnvelopeProcessorService {
..
} in message.buckets.into_values()
{
let buckets = self
let mut buckets = self
.rate_limit_buckets(scoping, &project_info, buckets)
.await;

if buckets.is_empty() {
continue;
}

if project_info
.config
.features
.has(Feature::GenerateBillingOutcome)
{
// Emit metric billing outcomes.
self.inner
.metric_outcomes
.track_billing_outcome(scoping, &mut buckets);
}

let retention = project_info
.config
.event_retention
Expand Down
43 changes: 40 additions & 3 deletions tests/integration/test_ai.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,10 @@ def test_ai_spans_example_transaction(
outcomes_consumer = outcomes_consumer()

project_id = 42
mini_sentry.add_full_project_config(project_id)

project = mini_sentry.add_full_project_config(project_id)
project["config"].setdefault("features", []).extend(
["organizations:relay-generate-billing-outcome"]
)
mini_sentry.global_config["aiModelMetadata"] = {
"version": 1,
"models": {
Expand Down Expand Up @@ -1298,12 +1300,47 @@ def test_ai_spans_example_transaction(

if relay_emits_accepted_outcome:
assert outcomes_consumer.get_aggregated_outcomes() == [
{
"category": DataCategory.TRANSACTION.value,
"key_id": 123,
"org_id": 1,
"outcome": 0,
"project_id": 42,
"quantity": 1,
},
{
"category": DataCategory.SPAN.value,
"key_id": 123,
"org_id": 1,
"outcome": 0,
"project_id": 42,
"quantity": 10,
},
{
"category": DataCategory.SPAN_INDEXED.value,
"key_id": 123,
"org_id": 1,
"outcome": 0,
"project_id": 42,
"quantity": 10,
}
},
]
else:
assert outcomes_consumer.get_aggregated_outcomes() == [
{
"category": DataCategory.TRANSACTION.value,
"key_id": 123,
"org_id": 1,
"outcome": 0,
"project_id": 42,
"quantity": 1,
},
{
"category": DataCategory.SPAN.value,
"key_id": 123,
"org_id": 1,
"outcome": 0,
"project_id": 42,
"quantity": 10,
},
]
9 changes: 6 additions & 3 deletions tests/integration/test_attachment_ref.py
Original file line number Diff line number Diff line change
Expand Up @@ -254,8 +254,11 @@ def test_attachment_ref_validation(
event_id = "515539018c9b4260a6f999572f1661ee"
project_id = 42
project_config = mini_sentry.add_full_project_config(project_id)
project_config["config"].setdefault("features", []).append(
"projects:relay-upload-endpoint"
project_config["config"].setdefault("features", []).extend(
[
"projects:relay-upload-endpoint",
"organizations:relay-generate-billing-outcome",
]
)

relay = relay_with_processing()
Expand All @@ -275,7 +278,7 @@ def test_attachment_ref_validation(

relay.send_envelope(project_id, envelope)

outcomes = outcomes_consumer.get_outcomes(n=3 if event_type == "transaction" else 2)
outcomes = outcomes_consumer.get_outcomes(n=5 if event_type == "transaction" else 2)
o = {DataCategory(o["category"]): o for o in outcomes}
assert o[DataCategory.ATTACHMENT]["reason"] == "invalid_placeholder_attachment"
assert o[DataCategory.ATTACHMENT]["quantity"] == expected_bytes_quantity
Expand Down
19 changes: 18 additions & 1 deletion tests/integration/test_attachmentsv2.py
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,7 @@ def test_attachment_with_matching_span_store(
project_config["config"]["features"] = [
"projects:span-v2-experimental-processing",
"projects:span-v2-attachment-processing",
"organizations:relay-generate-billing-outcome",
]
relay = relay_with_processing()

Expand Down Expand Up @@ -424,8 +425,16 @@ def test_attachment_with_matching_span_store(
objectstore = objectstore(usecase="trace_attachments", project_id=project_id)
assert objectstore.get(metadata["attachment_id"]).payload.read() == body

outcomes = outcomes_consumer.get_aggregated_outcomes(n=3)
outcomes = outcomes_consumer.get_aggregated_outcomes(n=5)
assert outcomes == [
{
"category": DataCategory.TRANSACTION.value,
"key_id": 123,
"org_id": 1,
"outcome": 0,
"project_id": 42,
"quantity": 1,
},
{
"category": DataCategory.ATTACHMENT.value,
"key_id": 123,
Expand All @@ -434,6 +443,14 @@ def test_attachment_with_matching_span_store(
"project_id": 42,
"quantity": 23,
},
{
"category": DataCategory.SPAN.value,
"key_id": 123,
"org_id": 1,
"outcome": 0,
"project_id": 42,
"quantity": 1,
},
{
"category": DataCategory.SPAN_INDEXED.value,
"key_id": 123,
Expand Down
1 change: 1 addition & 0 deletions tests/integration/test_otlp_logs.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ def test_otlp_logs_multiple_records(
project_config = mini_sentry.add_full_project_config(project_id)
project_config["config"]["features"] = [
"organizations:ourlogs-ingestion",
"organizations:relay-generate-billing-outcome",
]
project_config["config"]["retentions"] = {
"log": {"standard": 30, "downsampled": 13 * 30},
Expand Down
Loading
Loading