1use relay_protocol::{Annotated, Array, Empty, FromValue, Getter, IntoValue, Val};
29
30use crate::processor::ProcessValue;
31use crate::protocol::{
32 AppContext, BrowserContext, ClientSdkInfo, Contexts, DefaultContext, DeviceContext, EventId,
33 LenientString, OTAUpdatesContext, OsContext, ProfileContext, Request, ResponseContext, Tags,
34 Timestamp, TraceContext, User,
35};
36use uuid::Uuid;
37
38#[derive(Clone, Debug, Default, PartialEq, Empty, FromValue, IntoValue, ProcessValue)]
39#[metastructure(process_func = "process_replay", value_type = "Replay")]
40pub struct Replay {
41 pub event_id: Annotated<EventId>,
59
60 pub replay_id: Annotated<EventId>,
73
74 #[metastructure(max_chars = 64)]
86 pub replay_type: Annotated<String>,
87
88 pub segment_id: Annotated<u64>,
102
103 pub timestamp: Annotated<Timestamp>,
129
130 pub replay_start_timestamp: Annotated<Timestamp>,
132
133 #[metastructure(pii = "true", max_depth = 7, max_bytes = 8192)]
135 pub urls: Annotated<Array<String>>,
136
137 #[metastructure(max_depth = 5, max_bytes = 2048)]
139 pub error_ids: Annotated<Array<Uuid>>,
140
141 #[metastructure(max_depth = 5, max_bytes = 2048)]
143 pub trace_ids: Annotated<Array<Uuid>>,
144
145 #[metastructure(skip_serialization = "empty")]
147 pub contexts: Annotated<Contexts>,
148
149 #[metastructure(max_chars = 64)]
154 pub platform: Annotated<String>,
155
156 #[metastructure(
161 max_chars = 200,
162 required = false,
163 trim_whitespace = true,
164 nonempty = true,
165 skip_serialization = "empty"
166 )]
167 pub release: Annotated<LenientString>,
168
169 #[metastructure(
177 allow_chars = "a-zA-Z0-9_.-",
178 trim_whitespace = true,
179 required = false,
180 nonempty = true,
181 max_chars = 64
182 )]
183 pub dist: Annotated<String>,
184
185 #[metastructure(
191 max_chars = 64,
192 nonempty = true,
193 required = false,
194 trim_whitespace = true
195 )]
196 pub environment: Annotated<String>,
197
198 #[metastructure(skip_serialization = "empty", pii = "true")]
202 pub tags: Annotated<Tags>,
203
204 #[metastructure(field = "type", max_chars = 64)]
206 pub ty: Annotated<String>,
207
208 #[metastructure(skip_serialization = "empty")]
210 pub user: Annotated<User>,
211
212 #[metastructure(skip_serialization = "empty")]
214 pub request: Annotated<Request>,
215
216 #[metastructure(field = "sdk")]
218 #[metastructure(skip_serialization = "empty")]
219 pub sdk: Annotated<ClientSdkInfo>,
220}
221
222impl Replay {
223 pub fn context<C: DefaultContext>(&self) -> Option<&C> {
225 self.contexts.value()?.get()
226 }
227
228 pub fn user_agent(&self) -> Option<&str> {
233 let headers = self.request.value()?.headers.value()?;
234
235 for item in headers.iter() {
236 if let Some((o_k, v)) = item.value() {
237 if let Some(k) = o_k.as_str() {
238 if k.eq_ignore_ascii_case("user-agent") {
239 return v.as_str();
240 }
241 }
242 }
243 }
244
245 None
246 }
247}
248
249impl Getter for Replay {
250 fn get_value(&self, path: &str) -> Option<Val<'_>> {
251 Some(match path.strip_prefix("event.")? {
252 "release" => self.release.as_str()?.into(),
254 "dist" => self.dist.as_str()?.into(),
255 "environment" => self.environment.as_str()?.into(),
256 "platform" => self.platform.as_str().unwrap_or("other").into(),
257
258 "user.email" => or_none(&self.user.value()?.email)?.into(),
260 "user.id" => or_none(&self.user.value()?.id)?.into(),
261 "user.ip_address" => self.user.value()?.ip_address.as_str()?.into(),
262 "user.name" => self.user.value()?.name.as_str()?.into(),
263 "user.segment" => or_none(&self.user.value()?.segment)?.into(),
264 "user.geo.city" => self.user.value()?.geo.value()?.city.as_str()?.into(),
265 "user.geo.country_code" => self
266 .user
267 .value()?
268 .geo
269 .value()?
270 .country_code
271 .as_str()?
272 .into(),
273 "user.geo.region" => self.user.value()?.geo.value()?.region.as_str()?.into(),
274 "user.geo.subdivision" => self.user.value()?.geo.value()?.subdivision.as_str()?.into(),
275 "request.method" => self.request.value()?.method.as_str()?.into(),
276 "request.url" => self.request.value()?.url.as_str()?.into(),
277 "sdk.name" => self.sdk.value()?.name.as_str()?.into(),
278 "sdk.version" => self.sdk.value()?.version.as_str()?.into(),
279
280 "sentry_user" => self.user.value()?.sentry_user.as_str()?.into(),
282
283 "contexts.app.in_foreground" => {
285 self.context::<AppContext>()?.in_foreground.value()?.into()
286 }
287 "contexts.device.arch" => self.context::<DeviceContext>()?.arch.as_str()?.into(),
288 "contexts.device.battery_level" => self
289 .context::<DeviceContext>()?
290 .battery_level
291 .value()?
292 .into(),
293 "contexts.device.brand" => self.context::<DeviceContext>()?.brand.as_str()?.into(),
294 "contexts.device.charging" => self.context::<DeviceContext>()?.charging.value()?.into(),
295 "contexts.device.family" => self.context::<DeviceContext>()?.family.as_str()?.into(),
296 "contexts.device.model" => self.context::<DeviceContext>()?.model.as_str()?.into(),
297 "contexts.device.locale" => self.context::<DeviceContext>()?.locale.as_str()?.into(),
298 "contexts.device.online" => self.context::<DeviceContext>()?.online.value()?.into(),
299 "contexts.device.orientation" => self
300 .context::<DeviceContext>()?
301 .orientation
302 .as_str()?
303 .into(),
304 "contexts.device.name" => self.context::<DeviceContext>()?.name.as_str()?.into(),
305 "contexts.device.screen_density" => self
306 .context::<DeviceContext>()?
307 .screen_density
308 .value()?
309 .into(),
310 "contexts.device.screen_dpi" => {
311 self.context::<DeviceContext>()?.screen_dpi.value()?.into()
312 }
313 "contexts.device.screen_width_pixels" => self
314 .context::<DeviceContext>()?
315 .screen_width_pixels
316 .value()?
317 .into(),
318 "contexts.device.screen_height_pixels" => self
319 .context::<DeviceContext>()?
320 .screen_height_pixels
321 .value()?
322 .into(),
323 "contexts.device.simulator" => {
324 self.context::<DeviceContext>()?.simulator.value()?.into()
325 }
326 "contexts.os.build" => self.context::<OsContext>()?.build.as_str()?.into(),
327 "contexts.os.kernel_version" => {
328 self.context::<OsContext>()?.kernel_version.as_str()?.into()
329 }
330 "contexts.os.name" => self.context::<OsContext>()?.name.as_str()?.into(),
331 "contexts.os.version" => self.context::<OsContext>()?.version.as_str()?.into(),
332 "contexts.browser.name" => self.context::<BrowserContext>()?.name.as_str()?.into(),
333 "contexts.browser.version" => {
334 self.context::<BrowserContext>()?.version.as_str()?.into()
335 }
336 "contexts.profile.profile_id" => {
337 (&self.context::<ProfileContext>()?.profile_id.value()?.0).into()
338 }
339 "contexts.device.uuid" => self.context::<DeviceContext>()?.uuid.value()?.into(),
340 "contexts.trace.status" => self
341 .context::<TraceContext>()?
342 .status
343 .value()?
344 .as_str()
345 .into(),
346 "contexts.trace.op" => self.context::<TraceContext>()?.op.as_str()?.into(),
347 "contexts.response.status_code" => self
348 .context::<ResponseContext>()?
349 .status_code
350 .value()?
351 .into(),
352 "contexts.unreal.crash_type" => match self.contexts.value()?.get_key("unreal")? {
353 super::Context::Other(context) => context.get("crash_type")?.value()?.into(),
354 _ => return None,
355 },
356 "contexts.ota_updates.channel" => self
357 .context::<OTAUpdatesContext>()?
358 .channel
359 .as_str()?
360 .into(),
361 "contexts.ota_updates.runtime_version" => self
362 .context::<OTAUpdatesContext>()?
363 .runtime_version
364 .as_str()?
365 .into(),
366 "contexts.ota_updates.update_id" => self
367 .context::<OTAUpdatesContext>()?
368 .update_id
369 .as_str()?
370 .into(),
371
372 path => {
374 if let Some(rest) = path.strip_prefix("tags.") {
375 self.tags.value()?.get(rest)?.into()
376 } else if let Some(rest) = path.strip_prefix("request.headers.") {
377 self.request
378 .value()?
379 .headers
380 .value()?
381 .get_header(rest)?
382 .into()
383 } else {
384 return None;
385 }
386 }
387 })
388 }
389}
390
391fn or_none(string: &Annotated<impl AsRef<str>>) -> Option<&str> {
392 match string.as_str() {
393 None | Some("") => None,
394 Some(other) => Some(other),
395 }
396}
397
398#[cfg(test)]
399mod tests {
400 use chrono::{TimeZone, Utc};
401
402 use crate::protocol::TagEntry;
403
404 use super::*;
405
406 #[test]
407 fn test_event_roundtrip() {
408 let json = r#"{
410 "event_id": "52df9022835246eeb317dbd739ccd059",
411 "replay_id": "52df9022835246eeb317dbd739ccd059",
412 "segment_id": 0,
413 "replay_type": "session",
414 "error_sample_rate": 0.5,
415 "session_sample_rate": 0.5,
416 "timestamp": 946684800.0,
417 "replay_start_timestamp": 946684800.0,
418 "urls": ["localhost:9000"],
419 "error_ids": ["52df9022835246eeb317dbd739ccd059"],
420 "trace_ids": ["52df9022835246eeb317dbd739ccd059"],
421 "platform": "myplatform",
422 "release": "myrelease",
423 "dist": "mydist",
424 "environment": "myenv",
425 "tags": [
426 [
427 "tag",
428 "value"
429 ]
430 ]
431}"#;
432
433 let replay = Annotated::new(Replay {
434 event_id: Annotated::new(EventId("52df9022835246eeb317dbd739ccd059".parse().unwrap())),
435 replay_id: Annotated::new(EventId("52df9022835246eeb317dbd739ccd059".parse().unwrap())),
436 replay_type: Annotated::new("session".to_string()),
437 segment_id: Annotated::new(0),
438 timestamp: Annotated::new(Utc.with_ymd_and_hms(2000, 1, 1, 0, 0, 0).unwrap().into()),
439 replay_start_timestamp: Annotated::new(
440 Utc.with_ymd_and_hms(2000, 1, 1, 0, 0, 0).unwrap().into(),
441 ),
442 urls: Annotated::new(vec![Annotated::new("localhost:9000".to_string())]),
443 error_ids: Annotated::new(vec![Annotated::new(
444 Uuid::parse_str("52df9022835246eeb317dbd739ccd059").unwrap(),
445 )]),
446 trace_ids: Annotated::new(vec![Annotated::new(
447 Uuid::parse_str("52df9022835246eeb317dbd739ccd059").unwrap(),
448 )]),
449 platform: Annotated::new("myplatform".to_string()),
450 release: Annotated::new("myrelease".to_string().into()),
451 dist: Annotated::new("mydist".to_string()),
452 environment: Annotated::new("myenv".to_string()),
453 tags: {
454 let items = vec![Annotated::new(TagEntry(
455 Annotated::new("tag".to_string()),
456 Annotated::new("value".to_string()),
457 ))];
458 Annotated::new(Tags(items.into()))
459 },
460 ..Default::default()
461 });
462
463 assert_eq!(replay, Annotated::from_json(json).unwrap());
464 }
465
466 #[test]
467 fn test_lenient_release() {
468 let input = r#"{"release":42}"#;
469 let output = r#"{"release":"42"}"#;
470 let event = Annotated::new(Replay {
471 release: Annotated::new("42".to_string().into()),
472 ..Default::default()
473 });
474
475 assert_eq!(event, Annotated::from_json(input).unwrap());
476 assert_eq!(output, event.to_json().unwrap());
477 }
478
479 #[test]
480 fn test_ota_updates_context_getter() {
481 let mut contexts = Contexts::new();
482 contexts.add(OTAUpdatesContext {
483 channel: Annotated::new("production".to_string()),
484 runtime_version: Annotated::new("1.0.0".to_string()),
485 update_id: Annotated::new("12345678-1234-1234-1234-1234567890ab".to_string()),
486 ..OTAUpdatesContext::default()
487 });
488
489 let replay = Replay {
490 contexts: Annotated::new(contexts),
491 ..Default::default()
492 };
493
494 assert_eq!(
495 Some(Val::String("production")),
496 replay.get_value("event.contexts.ota_updates.channel")
497 );
498 assert_eq!(
499 Some(Val::String("1.0.0")),
500 replay.get_value("event.contexts.ota_updates.runtime_version")
501 );
502 assert_eq!(
503 Some(Val::String("12345678-1234-1234-1234-1234567890ab")),
504 replay.get_value("event.contexts.ota_updates.update_id")
505 );
506 }
507}