Skip to content

Commit 6acd029

Browse files
noahsmartinclaude
andcommitted
feat(sessions): Add EAP double-write for user sessions
When the `UserSessionsEap` feature flag is enabled, session data is sent both through the legacy metrics pipeline and directly to the snuba-items topic as TRACE_ITEM_TYPE_USER_SESSION TraceItems. This enables migration to the new EAP-based user sessions storage. Includes a Datadog metric `sessions.eap.produced` tagged with `session_type` (update/aggregate) to track EAP writes. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 92e10ea commit 6acd029

File tree

6 files changed

+671
-17
lines changed

6 files changed

+671
-17
lines changed

relay-dynamic-config/src/feature.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,15 @@ pub enum Feature {
138138
/// Enable the experimental Trace Attachment pipeline in Relay.
139139
#[serde(rename = "projects:trace-attachment-processing")]
140140
TraceAttachmentProcessing,
141+
/// Enable EAP (Event Analytics Platform) double-write for user sessions.
142+
///
143+
/// When enabled, session data is sent both through the legacy metrics pipeline
144+
/// and directly to the snuba-items topic as TRACE_ITEM_TYPE_USER_SESSION.
145+
/// This enables migration to the new EAP-based user sessions storage.
146+
///
147+
/// Serialized as `organizations:user-sessions-eap`.
148+
#[serde(rename = "organizations:user-sessions-eap")]
149+
UserSessionsEap,
141150
/// Forward compatibility.
142151
#[doc(hidden)]
143152
#[serde(other)]

relay-dynamic-config/src/project.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,9 @@ pub struct RetentionsConfig {
262262
/// Retention settings for attachments.
263263
#[serde(skip_serializing_if = "Option::is_none")]
264264
pub trace_attachment: Option<RetentionConfig>,
265+
/// Retention settings for user sessions (EAP).
266+
#[serde(skip_serializing_if = "Option::is_none")]
267+
pub session: Option<RetentionConfig>,
265268
}
266269

267270
impl RetentionsConfig {
@@ -271,9 +274,14 @@ impl RetentionsConfig {
271274
span,
272275
trace_metric,
273276
trace_attachment,
277+
session,
274278
} = self;
275279

276-
log.is_none() && span.is_none() && trace_metric.is_none() && trace_attachment.is_none()
280+
log.is_none()
281+
&& span.is_none()
282+
&& trace_metric.is_none()
283+
&& trace_attachment.is_none()
284+
&& session.is_none()
277285
}
278286
}
279287

relay-server/src/processing/sessions/mod.rs

Lines changed: 113 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,13 @@ use crate::managed::{Counted, Managed, ManagedEnvelope, OutcomeError, Quantities
99
use crate::processing::sessions::process::Expansion;
1010
use crate::processing::{self, Context, CountRateLimited, Forward, Output, QuotaRateLimiter};
1111
use crate::services::outcome::Outcome;
12+
#[cfg(feature = "processing")]
13+
use crate::statsd::RelayCounters;
1214

1315
mod filter;
1416
mod process;
17+
#[cfg(feature = "processing")]
18+
mod store;
1519

1620
type Result<T, E = Error> = std::result::Result<T, E>;
1721

@@ -91,7 +95,9 @@ impl processing::Processor for SessionsProcessor {
9195
) -> Result<Output<Self::Output>, Rejected<Self::Error>> {
9296
let mut sessions = match process::expand(sessions, ctx) {
9397
Expansion::Continue(sessions) => sessions,
94-
Expansion::Forward(sessions) => return Ok(Output::just(SessionsOutput(sessions))),
98+
Expansion::Forward(sessions) => {
99+
return Ok(Output::just(SessionsOutput::Forward(sessions)));
100+
}
95101
};
96102

97103
// We can apply filters before normalization here, as our filters currently do not depend
@@ -103,31 +109,125 @@ impl processing::Processor for SessionsProcessor {
103109

104110
let sessions = self.limiter.enforce_quotas(sessions, ctx).await?;
105111

106-
let sessions = process::extract(sessions, ctx);
107-
Ok(Output::metrics(sessions))
112+
// Check if EAP user sessions double-write is enabled.
113+
// This feature sends session data to both the legacy metrics pipeline
114+
// and directly to the snuba-items topic as TRACE_ITEM_TYPE_USER_SESSION.
115+
let eap_enabled = ctx
116+
.project_info
117+
.config
118+
.features
119+
.has(relay_dynamic_config::Feature::UserSessionsEap);
120+
121+
let (metrics, eap_sessions) = process::extract_with_eap(sessions, ctx, eap_enabled);
122+
123+
if let Some(eap_sessions) = eap_sessions {
124+
// Return both the EAP sessions for storage and the extracted metrics.
125+
Ok(Output {
126+
main: Some(SessionsOutput::Store(eap_sessions)),
127+
metrics: Some(metrics),
128+
})
129+
} else {
130+
// Legacy path: only return metrics.
131+
Ok(Output::metrics(metrics))
132+
}
108133
}
109134
}
110135

111136
/// Output produced by the [`SessionsProcessor`].
112137
#[derive(Debug)]
113-
pub struct SessionsOutput(Managed<SerializedSessions>);
138+
pub enum SessionsOutput {
139+
/// Sessions that should be forwarded (non-processing relay).
140+
Forward(Managed<SerializedSessions>),
141+
/// Sessions that should be stored to EAP (processing relay with feature enabled).
142+
Store(Managed<ExpandedSessions>),
143+
}
114144

115145
impl Forward for SessionsOutput {
116146
fn serialize_envelope(
117147
self,
118148
_: processing::ForwardContext<'_>,
119149
) -> Result<Managed<Box<Envelope>>, Rejected<()>> {
120-
Ok(self.0.map(|sessions, _| sessions.serialize_envelope()))
150+
match self {
151+
Self::Forward(sessions) => {
152+
Ok(sessions.map(|sessions, _| sessions.serialize_envelope()))
153+
}
154+
Self::Store(sessions) => {
155+
// EAP sessions should be stored, not serialized to envelope.
156+
Err(sessions
157+
.internal_error("EAP sessions should be stored, not serialized to envelope"))
158+
}
159+
}
121160
}
122161

123162
#[cfg(feature = "processing")]
124163
fn forward_store(
125164
self,
126-
_: processing::forward::StoreHandle<'_>,
127-
_: processing::ForwardContext<'_>,
165+
s: processing::forward::StoreHandle<'_>,
166+
ctx: processing::ForwardContext<'_>,
128167
) -> Result<(), Rejected<()>> {
129-
let SessionsOutput(sessions) = self;
130-
Err(sessions.internal_error("sessions should always be extracted into metrics"))
168+
match self {
169+
Self::Forward(sessions) => {
170+
// Non-processing relay path - sessions should have been extracted to metrics.
171+
Err(sessions.internal_error("sessions should always be extracted into metrics"))
172+
}
173+
Self::Store(sessions) => {
174+
// EAP double-write path: convert expanded sessions to TraceItems and store.
175+
let store_ctx = store::Context {
176+
received_at: sessions.received_at(),
177+
scoping: sessions.scoping(),
178+
retention: ctx.retention(|r| r.session.as_ref()),
179+
};
180+
181+
// Split sessions into updates and aggregates, keeping track of the aggregates
182+
// for later processing.
183+
let (updates_managed, aggregates) =
184+
sessions.split_once(|s, _| (s.updates, s.aggregates));
185+
186+
// Convert and store each session update.
187+
for session in updates_managed.split(|updates| updates) {
188+
let item = session.try_map(|session, _| {
189+
Ok::<_, std::convert::Infallible>(store::convert_session_update(
190+
&session, &store_ctx,
191+
))
192+
});
193+
if let Ok(item) = item {
194+
s.store(item);
195+
relay_statsd::metric!(
196+
counter(RelayCounters::SessionsEapProduced) += 1,
197+
session_type = "update"
198+
);
199+
}
200+
}
201+
202+
// Convert and store each session aggregate.
203+
// Aggregates are expanded into individual session rows to unify the format.
204+
for aggregate_batch in aggregates.split(|aggs| aggs) {
205+
let release = aggregate_batch.attributes.release.clone();
206+
let environment = aggregate_batch.attributes.environment.clone();
207+
208+
for aggregate in aggregate_batch.split(|batch| batch.aggregates) {
209+
// Convert aggregate to multiple individual session items
210+
let items = store::convert_session_aggregate(
211+
&aggregate,
212+
&release,
213+
environment.as_deref(),
214+
&store_ctx,
215+
);
216+
217+
for item in items {
218+
let managed_item = aggregate.wrap(item);
219+
s.store(managed_item);
220+
relay_statsd::metric!(
221+
counter(RelayCounters::SessionsEapProduced) += 1,
222+
session_type = "aggregate"
223+
);
224+
}
225+
}
226+
}
227+
228+
Ok(())
229+
}
230+
}
131231
}
132232
}
133233

@@ -163,15 +263,15 @@ impl Counted for SerializedSessions {
163263
}
164264
}
165265

166-
#[derive(Debug)]
266+
#[derive(Clone, Debug)]
167267
pub struct ExpandedSessions {
168268
/// Original envelope headers.
169-
headers: EnvelopeHeaders,
269+
pub(crate) headers: EnvelopeHeaders,
170270

171271
/// A list of parsed session updates.
172-
updates: Vec<SessionUpdate>,
272+
pub(crate) updates: Vec<SessionUpdate>,
173273
/// A list of parsed session aggregates.
174-
aggregates: Vec<SessionAggregates>,
274+
pub(crate) aggregates: Vec<SessionAggregates>,
175275
}
176276

177277
impl Counted for ExpandedSessions {

relay-server/src/processing/sessions/process.rs

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -212,14 +212,38 @@ fn normalize_attributes(attrs: &mut SessionAttributes, ctx: &NormalizeContext<'_
212212
Ok(())
213213
}
214214

215-
pub fn extract(sessions: Managed<ExpandedSessions>, ctx: Context<'_>) -> Managed<ExtractedMetrics> {
215+
/// Extracts session metrics and optionally returns the expanded sessions for EAP storage.
216+
///
217+
/// When `eap_enabled` is true, this function returns both the extracted metrics (for the legacy
218+
/// pipeline) and the expanded sessions (for double-write to the snuba-items topic as
219+
/// TRACE_ITEM_TYPE_USER_SESSION).
220+
///
221+
/// This enables a gradual migration from the legacy session metrics to the new EAP-based
222+
/// user sessions storage, with both paths running in parallel during the migration period.
223+
pub fn extract_with_eap(
224+
sessions: Managed<ExpandedSessions>,
225+
ctx: Context<'_>,
226+
eap_enabled: bool,
227+
) -> (Managed<ExtractedMetrics>, Option<Managed<ExpandedSessions>>) {
216228
let should_extract_abnormal_mechanism = ctx
217229
.project_info
218230
.config
219231
.session_metrics
220232
.should_extract_abnormal_mechanism();
221233

222-
sessions.map(|sessions, records| {
234+
// If EAP is enabled, we need to clone the sessions before consuming them for metrics.
235+
// Use `wrap` to create a new Managed with the same metadata but cloned data.
236+
let eap_sessions = if eap_enabled {
237+
Some(sessions.wrap(ExpandedSessions {
238+
headers: sessions.headers.clone(),
239+
updates: sessions.updates.clone(),
240+
aggregates: sessions.aggregates.clone(),
241+
}))
242+
} else {
243+
None
244+
};
245+
246+
let metrics = sessions.map(|sessions, records| {
223247
let mut metrics = Vec::new();
224248
let meta = sessions.headers.meta();
225249

@@ -253,5 +277,7 @@ pub fn extract(sessions: Managed<ExpandedSessions>, ctx: Context<'_>) -> Managed
253277
project_metrics: metrics,
254278
sampling_metrics: Vec::new(),
255279
}
256-
})
280+
});
281+
282+
(metrics, eap_sessions)
257283
}

0 commit comments

Comments
 (0)