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