Skip to main content

fakecloud_xray/
service.rs

1//! AWS X-Ray (`xray`) restJson1 dispatch + operation handlers.
2//!
3//! Every X-Ray operation is a fixed `POST /<UriPath>`, so requests route on the
4//! `@http` URI path. State is account-partitioned and persisted. The data-plane
5//! reads (`BatchGetTraces`, `GetTraceSummaries`, `GetServiceGraph`,
6//! `GetTraceGraph`, `GetTimeSeriesServiceStatistics`) compute deterministically
7//! over the segments ingested by `PutTraceSegments`; the control-plane ops
8//! (groups, sampling rules, encryption config, resource policies, indexing rule,
9//! trace-segment destination, tags) are persisted CRUD.
10
11use std::collections::BTreeSet;
12use std::sync::Arc;
13
14use async_trait::async_trait;
15use http::{Method, StatusCode};
16use serde_json::{json, Map, Value};
17use tokio::sync::Mutex as AsyncMutex;
18use uuid::Uuid;
19
20use fakecloud_core::service::{AwsRequest, AwsResponse, AwsService, AwsServiceError};
21use fakecloud_persistence::SnapshotStore;
22
23use crate::graph::build_service_graph;
24use crate::persistence::save_snapshot;
25use crate::segment::{parse_segment, StoredSegment};
26use crate::state::{now_epoch, SharedXrayState, XrayData, DEFAULT_SAMPLING_RULE};
27
28/// Every operation name in the AWS X-Ray Smithy model (38 operations).
29pub const XRAY_ACTIONS: &[&str] = &[
30    "BatchGetTraces",
31    "CancelTraceRetrieval",
32    "CreateGroup",
33    "CreateSamplingRule",
34    "DeleteGroup",
35    "DeleteResourcePolicy",
36    "DeleteSamplingRule",
37    "GetEncryptionConfig",
38    "GetGroup",
39    "GetGroups",
40    "GetIndexingRules",
41    "GetInsight",
42    "GetInsightEvents",
43    "GetInsightImpactGraph",
44    "GetInsightSummaries",
45    "GetRetrievedTracesGraph",
46    "GetSamplingRules",
47    "GetSamplingStatisticSummaries",
48    "GetSamplingTargets",
49    "GetServiceGraph",
50    "GetTimeSeriesServiceStatistics",
51    "GetTraceGraph",
52    "GetTraceSegmentDestination",
53    "GetTraceSummaries",
54    "ListResourcePolicies",
55    "ListRetrievedTraces",
56    "ListTagsForResource",
57    "PutEncryptionConfig",
58    "PutResourcePolicy",
59    "PutTelemetryRecords",
60    "PutTraceSegments",
61    "StartTraceRetrieval",
62    "TagResource",
63    "UntagResource",
64    "UpdateGroup",
65    "UpdateIndexingRule",
66    "UpdateSamplingRule",
67    "UpdateTraceSegmentDestination",
68];
69
70/// Operations that mutate persisted state on success (so a snapshot is taken).
71const MUTATING: &[&str] = &[
72    "PutTraceSegments",
73    "CreateGroup",
74    "UpdateGroup",
75    "DeleteGroup",
76    "CreateSamplingRule",
77    "UpdateSamplingRule",
78    "DeleteSamplingRule",
79    "PutEncryptionConfig",
80    "PutResourcePolicy",
81    "DeleteResourcePolicy",
82    "TagResource",
83    "UntagResource",
84    "UpdateTraceSegmentDestination",
85    "UpdateIndexingRule",
86    "StartTraceRetrieval",
87    "CancelTraceRetrieval",
88];
89
90pub struct XrayService {
91    state: SharedXrayState,
92    snapshot_store: Option<Arc<dyn SnapshotStore>>,
93    snapshot_lock: Arc<AsyncMutex<()>>,
94}
95
96impl XrayService {
97    pub fn new(state: SharedXrayState) -> Self {
98        Self {
99            state,
100            snapshot_store: None,
101            snapshot_lock: Arc::new(AsyncMutex::new(())),
102        }
103    }
104
105    pub fn with_snapshot_store(mut self, store: Arc<dyn SnapshotStore>) -> Self {
106        self.snapshot_store = Some(store);
107        self
108    }
109
110    async fn save(&self) {
111        save_snapshot(
112            &self.state,
113            self.snapshot_store.clone(),
114            &self.snapshot_lock,
115        )
116        .await;
117    }
118
119    /// Persist hook for the CloudFormation provisioner; `None` in memory mode.
120    pub fn snapshot_hook(&self) -> Option<fakecloud_persistence::SnapshotHook> {
121        let store = self.snapshot_store.clone()?;
122        let state = self.state.clone();
123        let lock = self.snapshot_lock.clone();
124        Some(Arc::new(move || {
125            let state = state.clone();
126            let store = store.clone();
127            let lock = lock.clone();
128            Box::pin(async move {
129                crate::persistence::save_snapshot(&state, Some(store), &lock).await;
130            })
131        }))
132    }
133
134    /// Route a request to an operation name by its `@http` URI path.
135    fn resolve_action(req: &AwsRequest) -> Option<&'static str> {
136        if req.method != Method::POST {
137            return None;
138        }
139        let raw = req.raw_path.split('?').next().unwrap_or(&req.raw_path);
140        let path = raw.strip_suffix('/').unwrap_or(raw);
141        let action = match path {
142            "/Traces" => "BatchGetTraces",
143            "/CancelTraceRetrieval" => "CancelTraceRetrieval",
144            "/CreateGroup" => "CreateGroup",
145            "/CreateSamplingRule" => "CreateSamplingRule",
146            "/DeleteGroup" => "DeleteGroup",
147            "/DeleteResourcePolicy" => "DeleteResourcePolicy",
148            "/DeleteSamplingRule" => "DeleteSamplingRule",
149            "/EncryptionConfig" => "GetEncryptionConfig",
150            "/GetGroup" => "GetGroup",
151            "/Groups" => "GetGroups",
152            "/GetIndexingRules" => "GetIndexingRules",
153            "/Insight" => "GetInsight",
154            "/InsightEvents" => "GetInsightEvents",
155            "/InsightImpactGraph" => "GetInsightImpactGraph",
156            "/InsightSummaries" => "GetInsightSummaries",
157            "/GetRetrievedTracesGraph" => "GetRetrievedTracesGraph",
158            "/GetSamplingRules" => "GetSamplingRules",
159            "/SamplingStatisticSummaries" => "GetSamplingStatisticSummaries",
160            "/SamplingTargets" => "GetSamplingTargets",
161            "/ServiceGraph" => "GetServiceGraph",
162            "/TimeSeriesServiceStatistics" => "GetTimeSeriesServiceStatistics",
163            "/TraceGraph" => "GetTraceGraph",
164            "/GetTraceSegmentDestination" => "GetTraceSegmentDestination",
165            "/TraceSummaries" => "GetTraceSummaries",
166            "/ListResourcePolicies" => "ListResourcePolicies",
167            "/ListRetrievedTraces" => "ListRetrievedTraces",
168            "/ListTagsForResource" => "ListTagsForResource",
169            "/PutEncryptionConfig" => "PutEncryptionConfig",
170            "/PutResourcePolicy" => "PutResourcePolicy",
171            "/TelemetryRecords" => "PutTelemetryRecords",
172            "/TraceSegments" => "PutTraceSegments",
173            "/StartTraceRetrieval" => "StartTraceRetrieval",
174            "/TagResource" => "TagResource",
175            "/UntagResource" => "UntagResource",
176            "/UpdateGroup" => "UpdateGroup",
177            "/UpdateIndexingRule" => "UpdateIndexingRule",
178            "/UpdateSamplingRule" => "UpdateSamplingRule",
179            "/UpdateTraceSegmentDestination" => "UpdateTraceSegmentDestination",
180            _ => return None,
181        };
182        Some(action)
183    }
184}
185
186#[async_trait]
187impl AwsService for XrayService {
188    fn service_name(&self) -> &str {
189        "xray"
190    }
191
192    async fn handle(&self, req: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
193        let Some(action) = Self::resolve_action(&req) else {
194            return Err(AwsServiceError::aws_error(
195                StatusCode::NOT_FOUND,
196                "UnknownOperationException",
197                format!("Unknown operation: {} {}", req.method, req.raw_path),
198            ));
199        };
200        let result = self.dispatch(action, &req);
201        let success = matches!(result.as_ref(), Ok(resp) if resp.status.is_success());
202        if MUTATING.contains(&action) && success {
203            self.save().await;
204        }
205        result
206    }
207
208    fn supported_actions(&self) -> &[&str] {
209        XRAY_ACTIONS
210    }
211}
212
213/// Per-request account + region context.
214struct Ctx {
215    account: String,
216    region: String,
217}
218
219impl XrayService {
220    fn dispatch(&self, action: &str, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
221        let body = parse_body(req)?;
222        crate::validate::validate_input(action, &body)?;
223        let ctx = Ctx {
224            account: req.account_id.clone(),
225            region: req.region.clone(),
226        };
227        match action {
228            "PutTraceSegments" => self.put_trace_segments(&ctx, &body),
229            "BatchGetTraces" => self.batch_get_traces(&ctx, &body),
230            "GetTraceSummaries" => self.get_trace_summaries(&ctx, &body),
231            "GetServiceGraph" => self.get_service_graph(&ctx, &body),
232            "GetTraceGraph" => self.get_trace_graph(&ctx, &body),
233            "GetTimeSeriesServiceStatistics" => self.get_time_series(&ctx, &body),
234            "CreateGroup" => self.create_group(&ctx, &body),
235            "GetGroup" => self.get_group(&ctx, &body),
236            "GetGroups" => self.get_groups(&ctx),
237            "UpdateGroup" => self.update_group(&ctx, &body),
238            "DeleteGroup" => self.delete_group(&ctx, &body),
239            "CreateSamplingRule" => self.create_sampling_rule(&ctx, &body),
240            "GetSamplingRules" => self.get_sampling_rules(&ctx),
241            "UpdateSamplingRule" => self.update_sampling_rule(&ctx, &body),
242            "DeleteSamplingRule" => self.delete_sampling_rule(&ctx, &body),
243            "GetSamplingTargets" => self.get_sampling_targets(&ctx, &body),
244            "GetSamplingStatisticSummaries" => Self::get_sampling_statistic_summaries(),
245            "GetEncryptionConfig" => self.get_encryption_config(&ctx),
246            "PutEncryptionConfig" => self.put_encryption_config(&ctx, &body),
247            "PutResourcePolicy" => self.put_resource_policy(&ctx, &body),
248            "DeleteResourcePolicy" => self.delete_resource_policy(&ctx, &body),
249            "ListResourcePolicies" => self.list_resource_policies(&ctx),
250            "TagResource" => self.tag_resource(&ctx, &body),
251            "UntagResource" => self.untag_resource(&ctx, &body),
252            "ListTagsForResource" => self.list_tags_for_resource(&ctx, &body),
253            "GetIndexingRules" => Self::get_indexing_rules(),
254            "UpdateIndexingRule" => Self::update_indexing_rule(&body),
255            "GetTraceSegmentDestination" => self.get_trace_segment_destination(&ctx),
256            "UpdateTraceSegmentDestination" => self.update_trace_segment_destination(&ctx, &body),
257            "PutTelemetryRecords" => Ok(ok(json!({}))),
258            "StartTraceRetrieval" => self.start_trace_retrieval(&ctx, &body),
259            "ListRetrievedTraces" => self.list_retrieved_traces(&ctx, &body),
260            "GetRetrievedTracesGraph" => self.get_retrieved_traces_graph(&ctx, &body),
261            "CancelTraceRetrieval" => self.cancel_trace_retrieval(&ctx, &body),
262            "GetInsight" => Self::get_insight(&body),
263            "GetInsightEvents" => Self::get_insight_events(&body),
264            "GetInsightImpactGraph" => Self::get_insight_impact_graph(&body),
265            "GetInsightSummaries" => Self::get_insight_summaries(),
266            _ => Err(AwsServiceError::action_not_implemented("xray", action)),
267        }
268    }
269
270    // ===================== data plane =====================
271
272    fn put_trace_segments(&self, ctx: &Ctx, body: &Value) -> Result<AwsResponse, AwsServiceError> {
273        let docs = body
274            .get("TraceSegmentDocuments")
275            .and_then(Value::as_array)
276            .cloned()
277            .unwrap_or_default();
278        let mut unprocessed: Vec<Value> = Vec::new();
279        let mut guard = self.state.write();
280        let data = guard.get_or_create(&ctx.account);
281        for doc in &docs {
282            let Some(s) = doc.as_str() else {
283                unprocessed.push(json!({
284                    "ErrorCode": "MalformedJson",
285                    "Message": "Trace segment document must be a JSON string."
286                }));
287                continue;
288            };
289            match parse_segment(s) {
290                Ok(seg) => {
291                    let entry = data.traces.entry(seg.trace_id.clone()).or_default();
292                    // A segment can be re-sent in pieces; replace any prior copy
293                    // with the same id.
294                    entry.retain(|e| e.id != seg.id);
295                    entry.push(seg);
296                }
297                Err(e) => {
298                    let mut m = Map::new();
299                    if let Some(id) = e.id {
300                        m.insert("Id".into(), json!(id));
301                    }
302                    m.insert("ErrorCode".into(), json!(e.code));
303                    m.insert("Message".into(), json!(e.message));
304                    unprocessed.push(Value::Object(m));
305                }
306            }
307        }
308        Ok(ok(json!({ "UnprocessedTraceSegments": unprocessed })))
309    }
310
311    fn batch_get_traces(&self, ctx: &Ctx, body: &Value) -> Result<AwsResponse, AwsServiceError> {
312        let ids = string_list(body, "TraceIds");
313        let guard = self.state.read();
314        let data = guard.get(&ctx.account);
315        let mut traces: Vec<Value> = Vec::new();
316        let mut unprocessed: Vec<Value> = Vec::new();
317        for id in &ids {
318            match data.and_then(|d| d.traces.get(id)) {
319                Some(segs) if !segs.is_empty() => traces.push(assemble_trace(id, segs)),
320                _ => unprocessed.push(json!(id)),
321            }
322        }
323        Ok(ok(json!({
324            "Traces": traces,
325            "UnprocessedTraceIds": unprocessed,
326        })))
327    }
328
329    fn get_trace_summaries(&self, ctx: &Ctx, body: &Value) -> Result<AwsResponse, AwsServiceError> {
330        let (start, end) = time_range(body);
331        let filter = body
332            .get("FilterExpression")
333            .and_then(Value::as_str)
334            .unwrap_or("");
335        let guard = self.state.read();
336        let data = guard.get(&ctx.account);
337        let mut summaries: Vec<Value> = Vec::new();
338        let mut processed: i64 = 0;
339        if let Some(data) = data {
340            for (id, segs) in &data.traces {
341                let Some(root_start) = segs.iter().map(|s| s.start_time).reduce(f64::min) else {
342                    continue;
343                };
344                if root_start < start || root_start > end {
345                    continue;
346                }
347                processed += 1;
348                if trace_matches_filter(filter, segs) {
349                    summaries.push(build_trace_summary(id, segs));
350                }
351            }
352        }
353        Ok(ok(json!({
354            "TraceSummaries": summaries,
355            "ApproximateTime": now_epoch(),
356            "TracesProcessedCount": processed,
357        })))
358    }
359
360    fn get_service_graph(&self, ctx: &Ctx, body: &Value) -> Result<AwsResponse, AwsServiceError> {
361        let (start, end) = time_range(body);
362        let guard = self.state.read();
363        let services = match guard.get(&ctx.account) {
364            Some(data) => {
365                let segs = segments_in_range(data, start, end);
366                build_service_graph(&segs)
367            }
368            None => Vec::new(),
369        };
370        Ok(ok(json!({
371            "StartTime": start,
372            "EndTime": end,
373            "Services": services,
374            "ContainsOldGroupVersions": false,
375        })))
376    }
377
378    fn get_trace_graph(&self, ctx: &Ctx, body: &Value) -> Result<AwsResponse, AwsServiceError> {
379        let ids: BTreeSet<String> = string_list(body, "TraceIds").into_iter().collect();
380        let guard = self.state.read();
381        let services = match guard.get(&ctx.account) {
382            Some(data) => {
383                let segs: Vec<&StoredSegment> = data
384                    .traces
385                    .iter()
386                    .filter(|(id, _)| ids.contains(*id))
387                    .flat_map(|(_, s)| s.iter())
388                    .collect();
389                build_service_graph(&segs)
390            }
391            None => Vec::new(),
392        };
393        Ok(ok(json!({ "Services": services })))
394    }
395
396    fn get_time_series(&self, ctx: &Ctx, body: &Value) -> Result<AwsResponse, AwsServiceError> {
397        let (start, end) = time_range(body);
398        // Bucket the window by `Period` seconds. Bound the number of buckets so
399        // an extreme or unbounded window (e.g. a negative StartTime, or a
400        // default `now`-wide range) can never spin into a multi-million-iteration
401        // loop; AWS itself caps the returned data points.
402        const MAX_BUCKETS: usize = 1440;
403        let mut period = body
404            .get("Period")
405            .and_then(Value::as_i64)
406            .filter(|p| *p > 0)
407            .unwrap_or(60) as f64;
408        let guard = self.state.read();
409        let mut out: Vec<Value> = Vec::new();
410        if let Some(data) = guard.get(&ctx.account) {
411            let span = end - start;
412            if span > 0.0 && period > 0.0 {
413                if span / period > MAX_BUCKETS as f64 {
414                    period = span / MAX_BUCKETS as f64;
415                }
416                let mut bucket = start;
417                let mut count = 0usize;
418                while bucket < end && count < MAX_BUCKETS {
419                    let bucket_end = (bucket + period).min(end);
420                    let segs: Vec<&StoredSegment> = data
421                        .traces
422                        .values()
423                        .flat_map(|s| s.iter())
424                        .filter(|s| s.start_time >= bucket && s.start_time < bucket_end)
425                        .collect();
426                    if !segs.is_empty() {
427                        out.push(json!({
428                            "Timestamp": bucket,
429                            "ServiceSummaryStatistics": aggregate_statistics(&segs),
430                            "ResponseTimeHistogram": [],
431                        }));
432                    }
433                    bucket = bucket_end;
434                    count += 1;
435                }
436            }
437        }
438        Ok(ok(json!({
439            "TimeSeriesServiceStatistics": out,
440            "ContainsOldGroupVersions": false,
441        })))
442    }
443
444    // ===================== groups =====================
445
446    fn create_group(&self, ctx: &Ctx, body: &Value) -> Result<AwsResponse, AwsServiceError> {
447        let name = body
448            .get("GroupName")
449            .and_then(Value::as_str)
450            .unwrap_or_default()
451            .to_string();
452        let mut guard = self.state.write();
453        let data = guard.get_or_create(&ctx.account);
454        if data.groups.contains_key(&name) {
455            return Err(invalid(&format!("Group {name} already exists.")));
456        }
457        let arn = group_arn(&ctx.region, &ctx.account, &name);
458        let mut group = Map::new();
459        group.insert("GroupName".into(), json!(name));
460        group.insert("GroupARN".into(), json!(arn));
461        group.insert(
462            "FilterExpression".into(),
463            body.get("FilterExpression").cloned().unwrap_or(json!("")),
464        );
465        group.insert(
466            "InsightsConfiguration".into(),
467            insights_config(body.get("InsightsConfiguration")),
468        );
469        data.groups.insert(name, Value::Object(group.clone()));
470        store_tags(data, &arn, body.get("Tags"));
471        Ok(ok(json!({ "Group": Value::Object(group) })))
472    }
473
474    fn get_group(&self, ctx: &Ctx, body: &Value) -> Result<AwsResponse, AwsServiceError> {
475        let guard = self.state.read();
476        let data = guard.get(&ctx.account);
477        let group = data
478            .and_then(|d| find_group(d, body))
479            .ok_or_else(|| invalid("Group not found."))?;
480        Ok(ok(json!({ "Group": group })))
481    }
482
483    fn get_groups(&self, ctx: &Ctx) -> Result<AwsResponse, AwsServiceError> {
484        let guard = self.state.read();
485        let groups: Vec<Value> = guard
486            .get(&ctx.account)
487            .map(|d| d.groups.values().cloned().collect())
488            .unwrap_or_default();
489        Ok(ok(json!({ "Groups": groups })))
490    }
491
492    fn update_group(&self, ctx: &Ctx, body: &Value) -> Result<AwsResponse, AwsServiceError> {
493        let mut guard = self.state.write();
494        let data = guard.get_or_create(&ctx.account);
495        let Some(name) = group_key(data, body) else {
496            return Err(invalid("Group not found."));
497        };
498        let group = data
499            .groups
500            .get_mut(&name)
501            .and_then(Value::as_object_mut)
502            .expect("group key resolved above");
503        if let Some(fe) = body.get("FilterExpression") {
504            group.insert("FilterExpression".into(), fe.clone());
505        }
506        if body.get("InsightsConfiguration").is_some() {
507            group.insert(
508                "InsightsConfiguration".into(),
509                insights_config(body.get("InsightsConfiguration")),
510            );
511        }
512        let out = group.clone();
513        Ok(ok(json!({ "Group": Value::Object(out) })))
514    }
515
516    fn delete_group(&self, ctx: &Ctx, body: &Value) -> Result<AwsResponse, AwsServiceError> {
517        let mut guard = self.state.write();
518        let data = guard.get_or_create(&ctx.account);
519        let Some(name) = group_key(data, body) else {
520            return Err(invalid("Group not found."));
521        };
522        if let Some(g) = data.groups.remove(&name) {
523            if let Some(arn) = g.get("GroupARN").and_then(Value::as_str) {
524                data.tags.remove(arn);
525            }
526        }
527        Ok(ok(json!({})))
528    }
529
530    // ===================== sampling rules =====================
531
532    fn create_sampling_rule(
533        &self,
534        ctx: &Ctx,
535        body: &Value,
536    ) -> Result<AwsResponse, AwsServiceError> {
537        let rule_in = body.get("SamplingRule").cloned().unwrap_or(json!({}));
538        let name = rule_in
539            .get("RuleName")
540            .and_then(Value::as_str)
541            .filter(|s| !s.is_empty())
542            .map(std::string::ToString::to_string)
543            .unwrap_or_else(|| format!("rule-{}", short_id()));
544        let mut guard = self.state.write();
545        let data = guard.get_or_create(&ctx.account);
546        if data.sampling_rules.contains_key(&name) {
547            return Err(invalid(&format!("Sampling rule {name} already exists.")));
548        }
549        let arn = sampling_rule_arn(&ctx.region, &ctx.account, &name);
550        let mut rule = rule_in.as_object().cloned().unwrap_or_default();
551        rule.insert("RuleName".into(), json!(name));
552        rule.insert("RuleARN".into(), json!(arn));
553        rule.entry("Version").or_insert(json!(1));
554        rule.entry("Attributes")
555            .or_insert(Value::Object(Map::new()));
556        let now = now_epoch();
557        let record = json!({
558            "SamplingRule": Value::Object(rule),
559            "CreatedAt": now,
560            "ModifiedAt": now,
561        });
562        data.sampling_rules.insert(name, record.clone());
563        store_tags(data, &arn, body.get("Tags"));
564        Ok(ok(json!({ "SamplingRuleRecord": record })))
565    }
566
567    fn get_sampling_rules(&self, ctx: &Ctx) -> Result<AwsResponse, AwsServiceError> {
568        let guard = self.state.read();
569        let records: Vec<Value> = guard
570            .get(&ctx.account)
571            .map(|d| d.sampling_rules.values().cloned().collect())
572            .unwrap_or_default();
573        Ok(ok(json!({ "SamplingRuleRecords": records })))
574    }
575
576    fn update_sampling_rule(
577        &self,
578        ctx: &Ctx,
579        body: &Value,
580    ) -> Result<AwsResponse, AwsServiceError> {
581        let update = body.get("SamplingRuleUpdate").cloned().unwrap_or(json!({}));
582        let mut guard = self.state.write();
583        let data = guard.get_or_create(&ctx.account);
584        let Some(name) = sampling_rule_key(data, &update) else {
585            return Err(invalid("Sampling rule not found."));
586        };
587        let record = data
588            .sampling_rules
589            .get_mut(&name)
590            .and_then(Value::as_object_mut)
591            .expect("rule key resolved above");
592        if let Some(rule) = record
593            .get_mut("SamplingRule")
594            .and_then(Value::as_object_mut)
595        {
596            for key in [
597                "ResourceARN",
598                "Priority",
599                "FixedRate",
600                "ReservoirSize",
601                "ServiceName",
602                "ServiceType",
603                "Host",
604                "HTTPMethod",
605                "URLPath",
606                "Attributes",
607            ] {
608                if let Some(v) = update.get(key) {
609                    rule.insert(key.to_string(), v.clone());
610                }
611            }
612        }
613        record.insert("ModifiedAt".into(), json!(now_epoch()));
614        let out = record.clone();
615        Ok(ok(json!({ "SamplingRuleRecord": Value::Object(out) })))
616    }
617
618    fn delete_sampling_rule(
619        &self,
620        ctx: &Ctx,
621        body: &Value,
622    ) -> Result<AwsResponse, AwsServiceError> {
623        let mut guard = self.state.write();
624        let data = guard.get_or_create(&ctx.account);
625        let Some(name) = sampling_rule_key(data, body) else {
626            return Err(invalid("Sampling rule not found."));
627        };
628        if name == DEFAULT_SAMPLING_RULE {
629            return Err(invalid("The Default sampling rule cannot be deleted."));
630        }
631        let record = data
632            .sampling_rules
633            .remove(&name)
634            .expect("rule key resolved above");
635        if let Some(arn) = record
636            .get("SamplingRule")
637            .and_then(|r| r.get("RuleARN"))
638            .and_then(Value::as_str)
639        {
640            data.tags.remove(arn);
641        }
642        Ok(ok(json!({ "SamplingRuleRecord": record })))
643    }
644
645    fn get_sampling_targets(
646        &self,
647        ctx: &Ctx,
648        body: &Value,
649    ) -> Result<AwsResponse, AwsServiceError> {
650        let docs = body
651            .get("SamplingStatisticsDocuments")
652            .and_then(Value::as_array)
653            .cloned()
654            .unwrap_or_default();
655        let guard = self.state.read();
656        let data = guard.get(&ctx.account);
657        let mut targets: Vec<Value> = Vec::new();
658        for doc in &docs {
659            let rule_name = doc
660                .get("RuleName")
661                .and_then(Value::as_str)
662                .unwrap_or(DEFAULT_SAMPLING_RULE);
663            let fixed_rate = data
664                .and_then(|d| d.sampling_rules.get(rule_name))
665                .and_then(|r| r.get("SamplingRule"))
666                .and_then(|r| r.get("FixedRate"))
667                .and_then(Value::as_f64)
668                .unwrap_or(0.05);
669            targets.push(json!({
670                "RuleName": rule_name,
671                "FixedRate": fixed_rate,
672                "ReservoirQuota": 1,
673                "ReservoirQuotaTTL": now_epoch() + 10.0,
674                "Interval": 10,
675            }));
676        }
677        Ok(ok(json!({
678            "SamplingTargetDocuments": targets,
679            "LastRuleModification": now_epoch(),
680            "UnprocessedStatistics": [],
681        })))
682    }
683
684    fn get_sampling_statistic_summaries() -> Result<AwsResponse, AwsServiceError> {
685        Ok(ok(json!({ "SamplingStatisticSummaries": [] })))
686    }
687
688    // ===================== encryption config =====================
689
690    fn get_encryption_config(&self, ctx: &Ctx) -> Result<AwsResponse, AwsServiceError> {
691        let guard = self.state.read();
692        let cfg = guard
693            .get(&ctx.account)
694            .and_then(|d| d.encryption_config.clone())
695            .unwrap_or_else(default_encryption_config);
696        Ok(ok(json!({ "EncryptionConfig": cfg })))
697    }
698
699    fn put_encryption_config(
700        &self,
701        ctx: &Ctx,
702        body: &Value,
703    ) -> Result<AwsResponse, AwsServiceError> {
704        let etype = body.get("Type").and_then(Value::as_str).unwrap_or("NONE");
705        let mut cfg = Map::new();
706        cfg.insert("Type".into(), json!(etype));
707        cfg.insert("Status".into(), json!("ACTIVE"));
708        if etype == "KMS" {
709            let key = body
710                .get("KeyId")
711                .and_then(Value::as_str)
712                .unwrap_or_default();
713            cfg.insert("KeyId".into(), json!(key));
714        }
715        let cfg = Value::Object(cfg);
716        let mut guard = self.state.write();
717        guard.get_or_create(&ctx.account).encryption_config = Some(cfg.clone());
718        Ok(ok(json!({ "EncryptionConfig": cfg })))
719    }
720
721    // ===================== resource policies =====================
722
723    fn put_resource_policy(&self, ctx: &Ctx, body: &Value) -> Result<AwsResponse, AwsServiceError> {
724        let name = body
725            .get("PolicyName")
726            .and_then(Value::as_str)
727            .unwrap_or_default()
728            .to_string();
729        let doc = body
730            .get("PolicyDocument")
731            .and_then(Value::as_str)
732            .unwrap_or_default()
733            .to_string();
734        let mut guard = self.state.write();
735        let data = guard.get_or_create(&ctx.account);
736        let policy = json!({
737            "PolicyName": name,
738            "PolicyDocument": doc,
739            "PolicyRevisionId": short_id(),
740            "LastUpdatedTime": now_epoch(),
741        });
742        data.resource_policies.insert(name, policy.clone());
743        Ok(ok(json!({ "ResourcePolicy": policy })))
744    }
745
746    fn delete_resource_policy(
747        &self,
748        ctx: &Ctx,
749        body: &Value,
750    ) -> Result<AwsResponse, AwsServiceError> {
751        let name = body
752            .get("PolicyName")
753            .and_then(Value::as_str)
754            .unwrap_or_default();
755        let mut guard = self.state.write();
756        guard
757            .get_or_create(&ctx.account)
758            .resource_policies
759            .remove(name);
760        Ok(ok(json!({})))
761    }
762
763    fn list_resource_policies(&self, ctx: &Ctx) -> Result<AwsResponse, AwsServiceError> {
764        let guard = self.state.read();
765        let policies: Vec<Value> = guard
766            .get(&ctx.account)
767            .map(|d| d.resource_policies.values().cloned().collect())
768            .unwrap_or_default();
769        Ok(ok(json!({ "ResourcePolicies": policies })))
770    }
771
772    // ===================== tagging =====================
773
774    fn tag_resource(&self, ctx: &Ctx, body: &Value) -> Result<AwsResponse, AwsServiceError> {
775        let arn = body
776            .get("ResourceARN")
777            .and_then(Value::as_str)
778            .unwrap_or_default()
779            .to_string();
780        let mut guard = self.state.write();
781        let data = guard.get_or_create(&ctx.account);
782        if !resource_exists(data, &arn) {
783            return Err(not_found(&format!("Resource {arn} not found.")));
784        }
785        let entry = data.tags.entry(arn).or_default();
786        if let Some(list) = body.get("Tags").and_then(Value::as_array) {
787            for tag in list {
788                if let (Some(k), Some(v)) = (
789                    tag.get("Key").and_then(Value::as_str),
790                    tag.get("Value").and_then(Value::as_str),
791                ) {
792                    entry.insert(k.to_string(), v.to_string());
793                }
794            }
795        }
796        if entry.len() > 50 {
797            return Err(AwsServiceError::aws_error(
798                StatusCode::BAD_REQUEST,
799                "TooManyTagsException",
800                "The maximum number of tags (50) has been exceeded.",
801            ));
802        }
803        Ok(ok(json!({})))
804    }
805
806    fn untag_resource(&self, ctx: &Ctx, body: &Value) -> Result<AwsResponse, AwsServiceError> {
807        let arn = body
808            .get("ResourceARN")
809            .and_then(Value::as_str)
810            .unwrap_or_default()
811            .to_string();
812        let mut guard = self.state.write();
813        let data = guard.get_or_create(&ctx.account);
814        if !resource_exists(data, &arn) {
815            return Err(not_found(&format!("Resource {arn} not found.")));
816        }
817        if let Some(entry) = data.tags.get_mut(&arn) {
818            for key in string_list(body, "TagKeys") {
819                entry.remove(&key);
820            }
821        }
822        Ok(ok(json!({})))
823    }
824
825    fn list_tags_for_resource(
826        &self,
827        ctx: &Ctx,
828        body: &Value,
829    ) -> Result<AwsResponse, AwsServiceError> {
830        let arn = body
831            .get("ResourceARN")
832            .and_then(Value::as_str)
833            .unwrap_or_default()
834            .to_string();
835        let guard = self.state.read();
836        let data = guard
837            .get(&ctx.account)
838            .ok_or_else(|| not_found(&format!("Resource {arn} not found.")))?;
839        if !resource_exists(data, &arn) {
840            return Err(not_found(&format!("Resource {arn} not found.")));
841        }
842        let tags: Vec<Value> = data
843            .tags
844            .get(&arn)
845            .map(|m| {
846                m.iter()
847                    .map(|(k, v)| json!({ "Key": k, "Value": v }))
848                    .collect()
849            })
850            .unwrap_or_default();
851        Ok(ok(json!({ "Tags": tags })))
852    }
853
854    // ===================== indexing rules =====================
855
856    fn get_indexing_rules() -> Result<AwsResponse, AwsServiceError> {
857        Ok(ok(json!({ "IndexingRules": [default_indexing_rule()] })))
858    }
859
860    fn update_indexing_rule(body: &Value) -> Result<AwsResponse, AwsServiceError> {
861        let name = body
862            .get("Name")
863            .and_then(Value::as_str)
864            .unwrap_or("Default");
865        Ok(ok(json!({
866            "IndexingRule": {
867                "Name": name,
868                "ModifiedAt": now_epoch(),
869                "Rule": body.get("Rule").cloned().unwrap_or_else(|| default_indexing_rule()["Rule"].clone()),
870            }
871        })))
872    }
873
874    // ===================== trace-segment destination =====================
875
876    fn get_trace_segment_destination(&self, ctx: &Ctx) -> Result<AwsResponse, AwsServiceError> {
877        let guard = self.state.read();
878        let dest = guard
879            .get(&ctx.account)
880            .map(|d| d.trace_segment_destination.clone())
881            .unwrap_or_else(|| "XRay".to_string());
882        Ok(ok(json!({ "Destination": dest, "Status": "ACTIVE" })))
883    }
884
885    fn update_trace_segment_destination(
886        &self,
887        ctx: &Ctx,
888        body: &Value,
889    ) -> Result<AwsResponse, AwsServiceError> {
890        let dest = body
891            .get("Destination")
892            .and_then(Value::as_str)
893            .unwrap_or("XRay")
894            .to_string();
895        let mut guard = self.state.write();
896        guard.get_or_create(&ctx.account).trace_segment_destination = dest.clone();
897        Ok(ok(json!({ "Destination": dest, "Status": "ACTIVE" })))
898    }
899
900    // ===================== trace retrieval (Transaction Search) =====================
901
902    fn start_trace_retrieval(
903        &self,
904        ctx: &Ctx,
905        body: &Value,
906    ) -> Result<AwsResponse, AwsServiceError> {
907        let token = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple());
908        let record = json!({
909            "TraceIds": body.get("TraceIds").cloned().unwrap_or(json!([])),
910            "StartTime": body.get("StartTime").cloned().unwrap_or(json!(0)),
911            "EndTime": body.get("EndTime").cloned().unwrap_or(json!(0)),
912        });
913        let mut guard = self.state.write();
914        guard
915            .get_or_create(&ctx.account)
916            .retrievals
917            .insert(token.clone(), record);
918        Ok(ok(json!({ "RetrievalToken": token })))
919    }
920
921    fn list_retrieved_traces(
922        &self,
923        ctx: &Ctx,
924        body: &Value,
925    ) -> Result<AwsResponse, AwsServiceError> {
926        let token = body
927            .get("RetrievalToken")
928            .and_then(Value::as_str)
929            .unwrap_or_default();
930        let format = body
931            .get("TraceFormat")
932            .and_then(Value::as_str)
933            .unwrap_or("XRAY");
934        let guard = self.state.read();
935        let exists = guard
936            .get(&ctx.account)
937            .map(|d| d.retrievals.contains_key(token))
938            .unwrap_or(false);
939        if !exists {
940            return Err(not_found("Trace retrieval not found for the given token."));
941        }
942        Ok(ok(json!({
943            "RetrievalStatus": "COMPLETE",
944            "TraceFormat": format,
945            "Traces": [],
946        })))
947    }
948
949    fn get_retrieved_traces_graph(
950        &self,
951        ctx: &Ctx,
952        body: &Value,
953    ) -> Result<AwsResponse, AwsServiceError> {
954        let token = body
955            .get("RetrievalToken")
956            .and_then(Value::as_str)
957            .unwrap_or_default();
958        let guard = self.state.read();
959        let data = guard
960            .get(&ctx.account)
961            .ok_or_else(|| not_found("Trace retrieval not found for the given token."))?;
962        let record = data
963            .retrievals
964            .get(token)
965            .ok_or_else(|| not_found("Trace retrieval not found for the given token."))?;
966        let ids: BTreeSet<String> = record
967            .get("TraceIds")
968            .and_then(Value::as_array)
969            .map(|a| {
970                a.iter()
971                    .filter_map(|v| v.as_str().map(std::string::ToString::to_string))
972                    .collect()
973            })
974            .unwrap_or_default();
975        let segs: Vec<&StoredSegment> = data
976            .traces
977            .iter()
978            .filter(|(id, _)| ids.contains(*id))
979            .flat_map(|(_, s)| s.iter())
980            .collect();
981        let services = build_service_graph(&segs);
982        Ok(ok(json!({
983            "RetrievalStatus": "COMPLETE",
984            "Services": services,
985        })))
986    }
987
988    fn cancel_trace_retrieval(
989        &self,
990        ctx: &Ctx,
991        body: &Value,
992    ) -> Result<AwsResponse, AwsServiceError> {
993        let token = body
994            .get("RetrievalToken")
995            .and_then(Value::as_str)
996            .unwrap_or_default()
997            .to_string();
998        let mut guard = self.state.write();
999        let data = guard.get_or_create(&ctx.account);
1000        if data.retrievals.remove(&token).is_none() {
1001            return Err(not_found("Trace retrieval not found for the given token."));
1002        }
1003        Ok(ok(json!({})))
1004    }
1005
1006    // ===================== insights =====================
1007    //
1008    // X-Ray insights are generated by the service from detected anomalies in
1009    // ingested traces; fakecloud does not run anomaly detection, so no insight
1010    // ids exist. A lookup by a specific insight id therefore returns the
1011    // declared `InvalidRequestException` (a nonexistent insight), while the
1012    // list/summary op returns an empty page.
1013
1014    fn get_insight(body: &Value) -> Result<AwsResponse, AwsServiceError> {
1015        let id = body.get("InsightId").and_then(Value::as_str).unwrap_or("");
1016        Err(invalid(&format!("Insight {id} not found.")))
1017    }
1018
1019    fn get_insight_events(body: &Value) -> Result<AwsResponse, AwsServiceError> {
1020        let id = body.get("InsightId").and_then(Value::as_str).unwrap_or("");
1021        Err(invalid(&format!("Insight {id} not found.")))
1022    }
1023
1024    fn get_insight_impact_graph(body: &Value) -> Result<AwsResponse, AwsServiceError> {
1025        let id = body.get("InsightId").and_then(Value::as_str).unwrap_or("");
1026        Err(invalid(&format!("Insight {id} not found.")))
1027    }
1028
1029    fn get_insight_summaries() -> Result<AwsResponse, AwsServiceError> {
1030        Ok(ok(json!({ "InsightSummaries": [] })))
1031    }
1032}
1033
1034// ===================== helpers =====================
1035
1036fn ok(v: Value) -> AwsResponse {
1037    AwsResponse::json_value(StatusCode::OK, v)
1038}
1039
1040fn parse_body(req: &AwsRequest) -> Result<Value, AwsServiceError> {
1041    if req.body.is_empty() {
1042        return Ok(json!({}));
1043    }
1044    serde_json::from_slice(&req.body)
1045        .map_err(|e| invalid(&format!("Request body is malformed: {e}")))
1046}
1047
1048fn invalid(msg: &str) -> AwsServiceError {
1049    AwsServiceError::aws_error(StatusCode::BAD_REQUEST, "InvalidRequestException", msg)
1050}
1051
1052/// Only the tagging and trace-retrieval operations declare
1053/// `ResourceNotFoundException`; all other not-present cases use
1054/// `InvalidRequestException` (which every X-Ray op declares).
1055fn not_found(msg: &str) -> AwsServiceError {
1056    AwsServiceError::aws_error(StatusCode::NOT_FOUND, "ResourceNotFoundException", msg)
1057}
1058
1059fn short_id() -> String {
1060    fakecloud_core::ids::short_id(12)
1061}
1062
1063fn group_arn(region: &str, account: &str, name: &str) -> String {
1064    format!(
1065        "arn:aws:xray:{region}:{account}:group/{name}/{}",
1066        short_id()
1067    )
1068}
1069
1070fn sampling_rule_arn(region: &str, account: &str, name: &str) -> String {
1071    format!("arn:aws:xray:{region}:{account}:sampling-rule/{name}")
1072}
1073
1074fn default_encryption_config() -> Value {
1075    json!({ "Type": "NONE", "Status": "ACTIVE" })
1076}
1077
1078fn default_indexing_rule() -> Value {
1079    json!({
1080        "Name": "Default",
1081        "ModifiedAt": now_epoch(),
1082        "Rule": { "Probabilistic": { "DesiredSamplingPercentage": 100.0, "ActualSamplingPercentage": 100.0 } },
1083    })
1084}
1085
1086fn insights_config(input: Option<&Value>) -> Value {
1087    let enabled = input
1088        .and_then(|c| c.get("InsightsEnabled"))
1089        .and_then(Value::as_bool)
1090        .unwrap_or(false);
1091    let notifications = input
1092        .and_then(|c| c.get("NotificationsEnabled"))
1093        .and_then(Value::as_bool)
1094        .unwrap_or(false);
1095    json!({ "InsightsEnabled": enabled, "NotificationsEnabled": notifications })
1096}
1097
1098/// Whether an ARN names a group or sampling rule that exists in this account.
1099fn resource_exists(data: &XrayData, arn: &str) -> bool {
1100    data.groups
1101        .values()
1102        .any(|g| g.get("GroupARN").and_then(Value::as_str) == Some(arn))
1103        || data.sampling_rules.values().any(|r| {
1104            r.get("SamplingRule")
1105                .and_then(|s| s.get("RuleARN"))
1106                .and_then(Value::as_str)
1107                == Some(arn)
1108        })
1109}
1110
1111fn store_tags(data: &mut XrayData, arn: &str, tags: Option<&Value>) {
1112    let Some(list) = tags.and_then(Value::as_array) else {
1113        return;
1114    };
1115    if list.is_empty() {
1116        return;
1117    }
1118    let entry = data.tags.entry(arn.to_string()).or_default();
1119    for tag in list {
1120        if let (Some(k), Some(v)) = (
1121            tag.get("Key").and_then(Value::as_str),
1122            tag.get("Value").and_then(Value::as_str),
1123        ) {
1124            entry.insert(k.to_string(), v.to_string());
1125        }
1126    }
1127}
1128
1129/// Resolve a group by `GroupName` or `GroupARN` from a request body, returning
1130/// its stored wire object.
1131fn find_group(data: &XrayData, body: &Value) -> Option<Value> {
1132    group_key(data, body).and_then(|k| data.groups.get(&k).cloned())
1133}
1134
1135/// Resolve a group's map key (`GroupName`) from a `GroupName` / `GroupARN` body.
1136fn group_key(data: &XrayData, body: &Value) -> Option<String> {
1137    if let Some(name) = body.get("GroupName").and_then(Value::as_str) {
1138        if data.groups.contains_key(name) {
1139            return Some(name.to_string());
1140        }
1141    }
1142    if let Some(arn) = body.get("GroupARN").and_then(Value::as_str) {
1143        return data
1144            .groups
1145            .iter()
1146            .find(|(_, g)| g.get("GroupARN").and_then(Value::as_str) == Some(arn))
1147            .map(|(k, _)| k.clone());
1148    }
1149    None
1150}
1151
1152/// Resolve a sampling rule's map key (`RuleName`) from a `RuleName` / `RuleARN`
1153/// body.
1154fn sampling_rule_key(data: &XrayData, body: &Value) -> Option<String> {
1155    if let Some(name) = body.get("RuleName").and_then(Value::as_str) {
1156        if data.sampling_rules.contains_key(name) {
1157            return Some(name.to_string());
1158        }
1159    }
1160    if let Some(arn) = body.get("RuleARN").and_then(Value::as_str) {
1161        return data
1162            .sampling_rules
1163            .iter()
1164            .find(|(_, r)| {
1165                r.get("SamplingRule")
1166                    .and_then(|s| s.get("RuleARN"))
1167                    .and_then(Value::as_str)
1168                    == Some(arn)
1169            })
1170            .map(|(k, _)| k.clone());
1171    }
1172    None
1173}
1174
1175fn string_list(body: &Value, key: &str) -> Vec<String> {
1176    body.get(key)
1177        .and_then(Value::as_array)
1178        .map(|a| {
1179            a.iter()
1180                .filter_map(|v| v.as_str().map(std::string::ToString::to_string))
1181                .collect()
1182        })
1183        .unwrap_or_default()
1184}
1185
1186/// The `[StartTime, EndTime]` window from a request body (epoch seconds).
1187fn time_range(body: &Value) -> (f64, f64) {
1188    let start = body.get("StartTime").and_then(Value::as_f64).unwrap_or(0.0);
1189    let end = body
1190        .get("EndTime")
1191        .and_then(Value::as_f64)
1192        .unwrap_or_else(now_epoch);
1193    (start, end)
1194}
1195
1196fn segments_in_range(data: &XrayData, start: f64, end: f64) -> Vec<&StoredSegment> {
1197    data.traces
1198        .values()
1199        .flat_map(|s| s.iter())
1200        .filter(|s| s.start_time >= start && s.start_time <= end)
1201        .collect()
1202}
1203
1204/// Assemble a stored trace into the `Trace` wire object with its segments echoed
1205/// verbatim as `{ Id, Document }`.
1206fn assemble_trace(id: &str, segs: &[StoredSegment]) -> Value {
1207    let start = segs.iter().map(|s| s.start_time).reduce(f64::min);
1208    let end = segs
1209        .iter()
1210        .filter_map(|s| s.end_time)
1211        .reduce(f64::max)
1212        .or(start);
1213    let duration = match (start, end) {
1214        (Some(s), Some(e)) => (e - s).max(0.0),
1215        _ => 0.0,
1216    };
1217    let segments: Vec<Value> = segs
1218        .iter()
1219        .map(|s| json!({ "Id": s.id, "Document": s.document }))
1220        .collect();
1221    json!({
1222        "Id": id,
1223        "Duration": duration,
1224        "LimitExceeded": false,
1225        "Segments": segments,
1226    })
1227}
1228
1229/// Build a `TraceSummary` for a stored trace.
1230fn build_trace_summary(id: &str, segs: &[StoredSegment]) -> Value {
1231    let start = segs
1232        .iter()
1233        .map(|s| s.start_time)
1234        .reduce(f64::min)
1235        .unwrap_or(0.0);
1236    let end = segs
1237        .iter()
1238        .filter_map(|s| s.end_time)
1239        .reduce(f64::max)
1240        .unwrap_or(start);
1241    let duration = (end - start).max(0.0);
1242    // The entry segment is the one with no parent.
1243    let root = segs.iter().find(|s| s.parent_id.is_none()).or(segs.first());
1244    let response_time = root.and_then(StoredSegment::duration).unwrap_or(duration);
1245
1246    let mut http = Map::new();
1247    if let Some(r) = root {
1248        if let Some(u) = &r.http_url {
1249            http.insert("HttpURL".into(), json!(u));
1250        }
1251        if let Some(s) = r.http_status {
1252            http.insert("HttpStatus".into(), json!(s));
1253        }
1254        if let Some(m) = &r.http_method {
1255            http.insert("HttpMethod".into(), json!(m));
1256        }
1257    }
1258
1259    let mut service_names: BTreeSet<&str> = BTreeSet::new();
1260    for s in segs {
1261        service_names.insert(s.name.as_str());
1262    }
1263    let service_ids: Vec<Value> = service_names
1264        .iter()
1265        .map(|n| json!({ "Name": n, "Names": [n], "Type": "AWS::EC2::Instance" }))
1266        .collect();
1267
1268    json!({
1269        "Id": id,
1270        "StartTime": start,
1271        "Duration": duration,
1272        "ResponseTime": response_time,
1273        "HasFault": segs.iter().any(|s| s.fault),
1274        "HasError": segs.iter().any(|s| s.error),
1275        "HasThrottle": segs.iter().any(|s| s.throttle),
1276        "IsPartial": false,
1277        "Http": Value::Object(http),
1278        "Annotations": {},
1279        "ServiceIds": service_ids,
1280    })
1281}
1282
1283/// Aggregate `ServiceStatistics` over a set of segments (used by the time-series
1284/// op). Mirrors the graph module's per-node accumulation across the whole set.
1285fn aggregate_statistics(segs: &[&StoredSegment]) -> Value {
1286    let mut ok_count = 0i64;
1287    let mut throttle = 0i64;
1288    let mut error_other = 0i64;
1289    let mut fault = 0i64;
1290    let mut total_rt = 0.0f64;
1291    for s in segs {
1292        if let Some(d) = s.duration() {
1293            total_rt += d;
1294        }
1295        if s.fault {
1296            fault += 1;
1297        } else if s.throttle {
1298            throttle += 1;
1299        } else if s.error {
1300            error_other += 1;
1301        } else {
1302            ok_count += 1;
1303        }
1304    }
1305    json!({
1306        "OkCount": ok_count,
1307        "ErrorStatistics": {
1308            "ThrottleCount": throttle,
1309            "OtherCount": error_other,
1310            "TotalCount": throttle + error_other,
1311        },
1312        "FaultStatistics": { "OtherCount": fault, "TotalCount": fault },
1313        "TotalCount": segs.len() as i64,
1314        "TotalResponseTime": (total_rt * 1_000_000.0).round() / 1_000_000.0,
1315    })
1316}
1317
1318/// Whether a trace matches a `GetTraceSummaries` filter expression.
1319///
1320/// fakecloud implements a documented subset of the X-Ray filter-expression
1321/// language: an empty/whitespace expression matches every trace; the boolean
1322/// keywords `fault`, `error`, and `throttle` match traces carrying that flag;
1323/// and `service("NAME")` matches traces containing a service node named `NAME`.
1324/// Any other (unsupported) expression is treated leniently as a match, so a
1325/// richer filter never silently drops data.
1326fn trace_matches_filter(filter: &str, segs: &[StoredSegment]) -> bool {
1327    let f = filter.trim();
1328    if f.is_empty() {
1329        return true;
1330    }
1331    if let Some(rest) = f.strip_prefix("service(") {
1332        if let Some(inner) = rest.strip_suffix(')') {
1333            let want = inner.trim().trim_matches(|c| c == '"' || c == '\'');
1334            return segs.iter().any(|s| s.name == want);
1335        }
1336    }
1337    match f {
1338        "fault" => segs.iter().any(|s| s.fault),
1339        "error" => segs.iter().any(|s| s.error),
1340        "throttle" => segs.iter().any(|s| s.throttle),
1341        _ => true,
1342    }
1343}
1344
1345#[cfg(test)]
1346mod tests {
1347    use super::*;
1348    use fakecloud_core::multi_account::MultiAccountState;
1349    use parking_lot::RwLock;
1350
1351    fn svc() -> XrayService {
1352        XrayService::new(Arc::new(RwLock::new(MultiAccountState::new(
1353            "000000000000",
1354            "us-east-1",
1355            "",
1356        ))))
1357    }
1358
1359    fn ctx() -> Ctx {
1360        Ctx {
1361            account: "000000000000".into(),
1362            region: "us-east-1".into(),
1363        }
1364    }
1365
1366    fn body_json(resp: &AwsResponse) -> Value {
1367        serde_json::from_slice(resp.body.expect_bytes()).expect("json response body")
1368    }
1369
1370    fn err_of(r: Result<AwsResponse, AwsServiceError>) -> AwsServiceError {
1371        match r {
1372            Ok(_) => panic!("expected an error"),
1373            Err(e) => e,
1374        }
1375    }
1376
1377    fn ingest(s: &XrayService, docs: &[&str]) {
1378        let arr: Vec<Value> = docs.iter().map(|d| json!(*d)).collect();
1379        let out = s
1380            .put_trace_segments(&ctx(), &json!({ "TraceSegmentDocuments": arr }))
1381            .unwrap();
1382        let body = body_json(&out);
1383        assert!(body["UnprocessedTraceSegments"]
1384            .as_array()
1385            .unwrap()
1386            .is_empty());
1387    }
1388
1389    #[test]
1390    fn put_then_batch_get_roundtrips() {
1391        let s = svc();
1392        ingest(
1393            &s,
1394            &[r#"{"trace_id":"1-aaaa","id":"1111","name":"web","start_time":1.0,"end_time":2.0}"#],
1395        );
1396        let out = s
1397            .batch_get_traces(&ctx(), &json!({ "TraceIds": ["1-aaaa", "1-missing"] }))
1398            .unwrap();
1399        let body = body_json(&out);
1400        assert_eq!(body["Traces"].as_array().unwrap().len(), 1);
1401        assert_eq!(body["Traces"][0]["Id"], json!("1-aaaa"));
1402        assert_eq!(body["UnprocessedTraceIds"], json!(["1-missing"]));
1403    }
1404
1405    #[test]
1406    fn trace_summaries_filter_by_time_and_expression() {
1407        let s = svc();
1408        ingest(
1409            &s,
1410            &[
1411                r#"{"trace_id":"1-ok","id":"1","name":"web","start_time":100.0,"end_time":101.0}"#,
1412                r#"{"trace_id":"1-bad","id":"2","name":"web","start_time":150.0,"end_time":151.0,"fault":true}"#,
1413            ],
1414        );
1415        let all = body_json(
1416            &s.get_trace_summaries(&ctx(), &json!({ "StartTime": 0, "EndTime": 200 }))
1417                .unwrap(),
1418        );
1419        assert_eq!(all["TraceSummaries"].as_array().unwrap().len(), 2);
1420        assert_eq!(all["TracesProcessedCount"], json!(2));
1421
1422        let faults = body_json(
1423            &s.get_trace_summaries(
1424                &ctx(),
1425                &json!({ "StartTime": 0, "EndTime": 200, "FilterExpression": "fault" }),
1426            )
1427            .unwrap(),
1428        );
1429        assert_eq!(faults["TraceSummaries"].as_array().unwrap().len(), 1);
1430        assert_eq!(faults["TraceSummaries"][0]["Id"], json!("1-bad"));
1431    }
1432
1433    #[test]
1434    fn service_graph_derives_edge() {
1435        let s = svc();
1436        ingest(
1437            &s,
1438            &[
1439                r#"{"trace_id":"1-a","id":"1","name":"web","start_time":1.0,"end_time":3.0,"subsegments":[{"id":"2","name":"db","namespace":"remote","start_time":1.5,"end_time":2.5}]}"#,
1440            ],
1441        );
1442        let body = body_json(
1443            &s.get_service_graph(&ctx(), &json!({ "StartTime": 0, "EndTime": 10 }))
1444                .unwrap(),
1445        );
1446        let services = body["Services"].as_array().unwrap();
1447        assert_eq!(services.len(), 2);
1448        let web = services.iter().find(|x| x["Name"] == json!("web")).unwrap();
1449        assert_eq!(web["Edges"].as_array().unwrap().len(), 1);
1450    }
1451
1452    #[test]
1453    fn create_get_group_roundtrips() {
1454        let s = svc();
1455        let created = body_json(
1456            &s.create_group(
1457                &ctx(),
1458                &json!({ "GroupName": "g1", "FilterExpression": "fault" }),
1459            )
1460            .unwrap(),
1461        );
1462        assert_eq!(created["Group"]["GroupName"], json!("g1"));
1463        let got = body_json(&s.get_group(&ctx(), &json!({ "GroupName": "g1" })).unwrap());
1464        assert_eq!(got["Group"]["FilterExpression"], json!("fault"));
1465        let groups = body_json(&s.get_groups(&ctx()).unwrap());
1466        assert_eq!(groups["Groups"].as_array().unwrap().len(), 1);
1467    }
1468
1469    #[test]
1470    fn default_sampling_rule_present_and_undeletable() {
1471        let s = svc();
1472        let rules = body_json(&s.get_sampling_rules(&ctx()).unwrap());
1473        let recs = rules["SamplingRuleRecords"].as_array().unwrap();
1474        assert!(recs
1475            .iter()
1476            .any(|r| r["SamplingRule"]["RuleName"] == json!("Default")));
1477        let err = err_of(s.delete_sampling_rule(&ctx(), &json!({ "RuleName": "Default" })));
1478        assert_eq!(err.status(), StatusCode::BAD_REQUEST);
1479    }
1480
1481    #[test]
1482    fn create_and_delete_sampling_rule() {
1483        let s = svc();
1484        let rule = json!({ "SamplingRule": {
1485            "RuleName": "r1", "ResourceARN": "*", "Priority": 5, "FixedRate": 0.1,
1486            "ReservoirSize": 2, "ServiceName": "*", "ServiceType": "*", "Host": "*",
1487            "HTTPMethod": "*", "URLPath": "*", "Version": 1
1488        }});
1489        let created = body_json(&s.create_sampling_rule(&ctx(), &rule).unwrap());
1490        assert_eq!(
1491            created["SamplingRuleRecord"]["SamplingRule"]["RuleName"],
1492            json!("r1")
1493        );
1494        let deleted = s.delete_sampling_rule(&ctx(), &json!({ "RuleName": "r1" }));
1495        assert!(deleted.is_ok());
1496    }
1497
1498    #[test]
1499    fn encryption_config_roundtrips() {
1500        let s = svc();
1501        let default = body_json(&s.get_encryption_config(&ctx()).unwrap());
1502        assert_eq!(default["EncryptionConfig"]["Type"], json!("NONE"));
1503        let put = body_json(
1504            &s.put_encryption_config(&ctx(), &json!({ "Type": "KMS", "KeyId": "alias/xray" }))
1505                .unwrap(),
1506        );
1507        assert_eq!(put["EncryptionConfig"]["Type"], json!("KMS"));
1508        let got = body_json(&s.get_encryption_config(&ctx()).unwrap());
1509        assert_eq!(got["EncryptionConfig"]["KeyId"], json!("alias/xray"));
1510    }
1511
1512    #[test]
1513    fn tagging_requires_existing_resource() {
1514        let s = svc();
1515        // Unknown ARN -> ResourceNotFoundException.
1516        let err = err_of(s.tag_resource(
1517            &ctx(),
1518            &json!({ "ResourceARN": "arn:aws:xray:us-east-1:000000000000:group/ghost/x", "Tags": [] }),
1519        ));
1520        assert_eq!(err.status(), StatusCode::NOT_FOUND);
1521
1522        let created = body_json(
1523            &s.create_group(&ctx(), &json!({ "GroupName": "tg" }))
1524                .unwrap(),
1525        );
1526        let arn = created["Group"]["GroupARN"].as_str().unwrap().to_string();
1527        s.tag_resource(
1528            &ctx(),
1529            &json!({ "ResourceARN": arn, "Tags": [{ "Key": "team", "Value": "obs" }] }),
1530        )
1531        .unwrap();
1532        let listed = body_json(
1533            &s.list_tags_for_resource(&ctx(), &json!({ "ResourceARN": arn }))
1534                .unwrap(),
1535        );
1536        assert_eq!(listed["Tags"][0]["Key"], json!("team"));
1537    }
1538
1539    #[test]
1540    fn trace_retrieval_lifecycle() {
1541        let s = svc();
1542        let started = body_json(
1543            &s.start_trace_retrieval(
1544                &ctx(),
1545                &json!({ "TraceIds": ["1-a"], "StartTime": 0, "EndTime": 1 }),
1546            )
1547            .unwrap(),
1548        );
1549        let token = started["RetrievalToken"].as_str().unwrap().to_string();
1550        let listed = body_json(
1551            &s.list_retrieved_traces(&ctx(), &json!({ "RetrievalToken": token }))
1552                .unwrap(),
1553        );
1554        assert_eq!(listed["RetrievalStatus"], json!("COMPLETE"));
1555        // Unknown token -> ResourceNotFoundException.
1556        let err = err_of(s.list_retrieved_traces(&ctx(), &json!({ "RetrievalToken": "nope" })));
1557        assert_eq!(err.status(), StatusCode::NOT_FOUND);
1558    }
1559
1560    fn req(path: &str) -> AwsRequest {
1561        AwsRequest {
1562            service: "xray".to_string(),
1563            action: String::new(),
1564            region: "us-east-1".to_string(),
1565            account_id: "000000000000".to_string(),
1566            request_id: "rid".to_string(),
1567            headers: http::HeaderMap::new(),
1568            query_params: std::collections::HashMap::new(),
1569            body: bytes::Bytes::new(),
1570            body_stream: parking_lot::Mutex::new(None),
1571            path_segments: vec![],
1572            raw_path: path.to_string(),
1573            raw_query: String::new(),
1574            method: Method::POST,
1575            is_query_protocol: false,
1576            access_key_id: None,
1577            principal: None,
1578        }
1579    }
1580
1581    #[test]
1582    fn resolve_action_routes_and_rejects() {
1583        assert_eq!(
1584            XrayService::resolve_action(&req("/TraceSegments")),
1585            Some("PutTraceSegments")
1586        );
1587        assert_eq!(
1588            XrayService::resolve_action(&req("/ServiceGraph")),
1589            Some("GetServiceGraph")
1590        );
1591        assert!(XrayService::resolve_action(&req("/NotARealOp")).is_none());
1592    }
1593}