From a5da4497997eaf2d424315faf8ba10d48f57cd43 Mon Sep 17 00:00:00 2001 From: "alexandre.fonseca" Date: Thu, 1 Oct 2026 14:20:35 +0000 Subject: [PATCH 1/3] fix(profiling): require --time-hint for flamegraph and profile-types trace scoping The backend's traceContext requires traceId, spanId and timeHint (epoch seconds) together and rejects a null timeHint, so `--trace-id` scoping on `explore flamegraph` and `profile-types list` always failed validation. - Build traceContext via the shared trace_context_json helper, which validates the three flags together and converts --time-hint to epoch seconds - Add --time-hint to `profiling explore flamegraph` and `profiling profile-types list` - Assert timeHint in the profile-types trace test; add a flamegraph trace-context request body test Co-Authored-By: Claude Opus 5.5 --- docs/EXAMPLES.md | 2 +- src/commands/profiling.rs | 138 +++++++++++++++++++++++++++++++++----- src/main.rs | 14 ++++ 3 files changed, 135 insertions(+), 19 deletions(-) diff --git a/docs/EXAMPLES.md b/docs/EXAMPLES.md index 8cb2c7d3..522dfc72 100644 --- a/docs/EXAMPLES.md +++ b/docs/EXAMPLES.md @@ -296,7 +296,7 @@ pup profiling services list --query="env:prod" --from="1h" --to="now" pup profiling profile-types list --query="service:my-service" --from="1h" --to="now" # Scoped to a specific trace -pup profiling profile-types list --trace-id="trace-abc" --span-id="span-123" --from="1h" --to="now" +pup profiling profile-types list --trace-id="trace-abc" --span-id="span-123" --time-hint="1700000000" --from="1h" --to="now" ``` ### Explore a Flame Graph / Top Stack Traces diff --git a/src/commands/profiling.rs b/src/commands/profiling.rs index ca4ddc02..a594766c 100644 --- a/src/commands/profiling.rs +++ b/src/commands/profiling.rs @@ -173,15 +173,13 @@ pub async fn profile_types_list( to: String, trace_id: Option, span_id: Option, + time_hint: Option, extra_headers: &[(&str, &str)], ) -> Result<()> { + let trace_context = trace_context_json(trace_id, span_id, time_hint)?; let mut body = json!({ "filter": filter_json(&query, &from, &to)? }); - if let Some(trace_id) = trace_id { - body["traceContext"] = json!({ - "traceId": trace_id, - "spanId": span_id, - "timeHint": null, - }); + if let Some(trace_context) = trace_context { + body["traceContext"] = trace_context; } let resp = raw_client::raw_post_with_headers( cfg, @@ -205,6 +203,7 @@ pub async fn explore_flamegraph( to: String, trace_id: Option, span_id: Option, + time_hint: Option, profile_id: Option, event_id: Option, attribute: Option, @@ -227,6 +226,7 @@ pub async fn explore_flamegraph( if profile_id.is_some() != event_id.is_some() { anyhow::bail!("--profile-id and --event-id must be used together"); } + let trace_context = trace_context_json(trace_id, span_id, time_hint)?; let mut body = json!({ "filter": filter_json(&query, &from, &to)?, @@ -242,12 +242,8 @@ pub async fn explore_flamegraph( "frameGrouping": if frame_grouping == "line" { "LINE" } else { "METHOD" }, "bypassKindTruncation": bypass_kind_truncation, }); - if let Some(trace_id) = trace_id { - body["traceContext"] = json!({ - "traceId": trace_id, - "spanId": span_id, - "timeHint": null, - }); + if let Some(trace_context) = trace_context { + body["traceContext"] = trace_context; } if let Some(profile_id) = profile_id { body["profileContext"] = json!({ @@ -927,6 +923,7 @@ mod tests { "now".into(), None, None, + None, &[], ) .await; @@ -950,9 +947,17 @@ mod tests { let body = r#"{"data":[],"meta":{"emptyStateReason":{"reason":"NO_DATA","description":"no profiles found"}}}"#; let _mock = mock_any(&mut server, "POST", body).await; - let result = - super::profile_types_list(&cfg, "".into(), "1h".into(), "now".into(), None, None, &[]) - .await; + let result = super::profile_types_list( + &cfg, + "".into(), + "1h".into(), + "now".into(), + None, + None, + None, + &[], + ) + .await; assert!( result.is_ok(), "profile_types_list failed: {:?}", @@ -975,6 +980,7 @@ mod tests { .match_body(mockito::Matcher::AllOf(vec![ mockito::Matcher::Regex(r#""traceId":"trace-abc""#.into()), mockito::Matcher::Regex(r#""spanId":"span-123""#.into()), + mockito::Matcher::Regex(r#""timeHint":"1700000000""#.into()), ])) .with_status(200) .with_header("content-type", "application/json") @@ -989,6 +995,7 @@ mod tests { "now".into(), Some("trace-abc".into()), Some("span-123".into()), + Some("1700000000".into()), &[], ) .await; @@ -1016,9 +1023,17 @@ mod tests { .create_async() .await; - let result = - super::profile_types_list(&cfg, "".into(), "1h".into(), "now".into(), None, None, &[]) - .await; + let result = super::profile_types_list( + &cfg, + "".into(), + "1h".into(), + "now".into(), + None, + None, + None, + &[], + ) + .await; assert!( result.is_err(), "expected error but got ok: {:?}", @@ -1042,6 +1057,7 @@ mod tests { Option, Option, Option, + Option, f64, i32, i32, @@ -1064,6 +1080,7 @@ mod tests { None, None, None, + None, 0.0, 0, 0, @@ -1093,6 +1110,7 @@ mod tests { to, trace_id, span_id, + time_hint, profile_id, event_id, attribute, @@ -1114,6 +1132,7 @@ mod tests { to, trace_id, span_id, + time_hint, profile_id, event_id, attribute, @@ -1152,6 +1171,7 @@ mod tests { to, trace_id, span_id, + time_hint, profile_id, event_id, attribute, @@ -1173,6 +1193,7 @@ mod tests { to, trace_id, span_id, + time_hint, profile_id, event_id, attribute, @@ -1223,6 +1244,7 @@ mod tests { to, trace_id, span_id, + time_hint, profile_id, event_id, attribute, @@ -1244,6 +1266,7 @@ mod tests { to, trace_id, span_id, + time_hint, profile_id, event_id, attribute, @@ -1286,6 +1309,7 @@ mod tests { to, trace_id, span_id, + time_hint, profile_id, event_id, attribute, @@ -1307,6 +1331,7 @@ mod tests { to, trace_id, span_id, + time_hint, profile_id, event_id, attribute, @@ -1353,6 +1378,7 @@ mod tests { to, trace_id, span_id, + time_hint, profile_id, event_id, attribute, @@ -1374,6 +1400,7 @@ mod tests { to, trace_id, span_id, + time_hint, profile_id, event_id, attribute, @@ -1412,6 +1439,7 @@ mod tests { to, trace_id, span_id, + time_hint, profile_id, event_id, attribute, @@ -1433,6 +1461,7 @@ mod tests { to, trace_id, span_id, + time_hint, profile_id, event_id, attribute, @@ -1454,6 +1483,79 @@ mod tests { std::env::remove_var("DD_TOKEN_STORAGE"); } + #[tokio::test] + async fn test_profiling_explore_flamegraph_trace_context_sends_time_hint() { + let _lock = lock_env().await; + std::env::set_var("DD_TOKEN_STORAGE", "file"); + let mut server = mockito::Server::new_async().await; + let cfg = test_config(&server.url()); + + let _mock = server + .mock("POST", "/api/unstable/profiling/pup/explore/flamegraph") + .match_body(mockito::Matcher::PartialJson(serde_json::json!({ + "traceContext": {"traceId": "trace-abc", "spanId": "span-123", "timeHint": "1700000000"} + }))) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"sortedStacktracesWithValues":[],"visualizationLink":{"title":"","url":""}}"#) + .create_async() + .await; + + let ( + profile_type, + _query, + from, + to, + _trace_id, + _span_id, + _time_hint, + profile_id, + event_id, + attribute, + percent_cutoff, + limit_top_stacktraces, + max_stack_trace_size, + frame_regex_filter, + endpoint_regex_filter, + attribute_values_regex_filter, + frame_format, + frame_grouping, + bypass_kind_truncation, + ) = flamegraph_args(); + let result = super::explore_flamegraph( + &cfg, + profile_type, + "".into(), + from, + to, + Some("trace-abc".into()), + Some("span-123".into()), + Some("1700000000".into()), + profile_id, + event_id, + attribute, + percent_cutoff, + limit_top_stacktraces, + max_stack_trace_size, + frame_regex_filter, + endpoint_regex_filter, + attribute_values_regex_filter, + frame_format, + frame_grouping, + bypass_kind_truncation, + &[], + ) + .await; + assert!( + result.is_ok(), + "explore_flamegraph with trace context failed: {:?}", + result.err() + ); + + cleanup_env(); + std::env::remove_var("DD_TOKEN_STORAGE"); + } + // ---- trace context ---- #[test] diff --git a/src/main.rs b/src/main.rs index 4a603c5c..36f472e2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4749,6 +4749,11 @@ enum ProfilingProfileTypesActions { trace_id: Option, #[arg(long, help = "Span ID (used with --trace-id)")] span_id: Option, + #[arg( + long, + help = "Approximate span time, e.g. a Unix timestamp or RFC3339 (required with --trace-id)" + )] + time_hint: Option, }, } @@ -4838,6 +4843,11 @@ enum ProfilingExploreActions { trace_id: Option, #[arg(long, help = "Span ID (used with --trace-id)")] span_id: Option, + #[arg( + long, + help = "Approximate span time, e.g. a Unix timestamp or RFC3339 (required with --trace-id)" + )] + time_hint: Option, #[arg( long, help = "Existing profile ID to scope the query instead of --query (used together with --event-id)" @@ -19486,6 +19496,7 @@ async fn main_inner() -> anyhow::Result<()> { to, trace_id, span_id, + time_hint, } => { commands::profiling::profile_types_list( &cfg, @@ -19494,6 +19505,7 @@ async fn main_inner() -> anyhow::Result<()> { to, trace_id, span_id, + time_hint, &extra_headers, ) .await?; @@ -19507,6 +19519,7 @@ async fn main_inner() -> anyhow::Result<()> { to, trace_id, span_id, + time_hint, profile_id, event_id, attribute, @@ -19528,6 +19541,7 @@ async fn main_inner() -> anyhow::Result<()> { to, trace_id, span_id, + time_hint, profile_id, event_id, attribute, From ae1eb75198de7dab9c9ff7f21e9818c5b780f199 Mon Sep 17 00:00:00 2001 From: "alexandre.fonseca" Date: Fri, 2 Oct 2026 09:36:43 +0000 Subject: [PATCH 2/3] docs(profiling): note reduced support guarantee for profiling commands `pup profiling` calls unstable /api/unstable/profiling/pup endpoints, which are supported for the latest pup release plus 30 days for older versions. Co-Authored-By: Claude Opus 5.5 --- docs/EXAMPLES.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/EXAMPLES.md b/docs/EXAMPLES.md index 522dfc72..c8580c5f 100644 --- a/docs/EXAMPLES.md +++ b/docs/EXAMPLES.md @@ -272,6 +272,11 @@ pup dbm samples search \ `pup profiling` wraps a small, pup-CLI-scoped Continuous Profiler API. +> **Reduced support guarantee:** `pup profiling` commands call unstable +> (`/api/unstable/profiling/pup/...`) endpoints. These are supported for the latest pup +> release, with older pup versions kept working for 30 days after a newer release. Upgrade +> pup regularly if you depend on these commands. + ### Search Profile Events ```bash pup profiling profiles list --query="service:my-service" --from="1h" --to="now" --limit=20 From 8cf8b737ae7b322f1d804d7e177ddfcf7f12ed56 Mon Sep 17 00:00:00 2001 From: "alexandre.fonseca" Date: Fri, 2 Oct 2026 14:21:40 +0000 Subject: [PATCH 3/3] docs(profiling): mention reduced support guarantee in command reference Co-Authored-By: Claude Opus 5.5 --- docs/COMMANDS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 1cef0eda..1dfb448c 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -200,6 +200,7 @@ pup infrastructure hosts list - **network** - Network monitoring (flows list, devices list/get/interfaces/tags, interfaces list/update) - **tags** - Host tag management (list, get, add, update, delete) - **profiling** - Continuous Profiler search/download (profiles list/download, services list, profile-types list, explore flamegraph/callgraph/timeline) + - Uses unstable `/api/unstable/profiling/pup/...` endpoints with a reduced support guarantee: the latest pup release, plus 30 days for older versions (see [EXAMPLES.md](EXAMPLES.md#continuous-profiler)) ### Security & Compliance - **security** - Security monitoring (rules, signals, findings, content-packs, risk-scores)