From 75968ad38440626ae47dd76da3f515096dd6584c Mon Sep 17 00:00:00 2001 From: "alexandre.fonseca" Date: Thu, 1 Oct 2026 13:57:28 +0000 Subject: [PATCH] feat(profiling): add continuous profiler timeline exploration Add `pup profiling explore timeline`, calling the new prof-gateway /api/unstable/profiling/pup/explore/timeline endpoint (PROF-16039). - explore_timeline in src/commands/profiling.rs: scoped by exactly one of --runtime-id (with --query), --trace-id (with --span-id/--time-hint) or --profile-id (with --event-id); optional focus window, focus event type, focus lane group, lane limit and Go critical-path analysis - ProfilingExploreActions::Timeline clap variant and dispatch in src/main.rs - Tests for each scope, request body shape, validation rules, null optional response fields, 400 errors and missing auth - Timeline examples in docs/EXAMPLES.md and command listing in docs/COMMANDS.md Co-Authored-By: Claude Opus 5.5 --- docs/COMMANDS.md | 4 +- docs/EXAMPLES.md | 20 ++ src/commands/profiling.rs | 439 ++++++++++++++++++++++++++++++++++++++ src/main.rs | 115 +++++++++- 4 files changed, 575 insertions(+), 3 deletions(-) diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 1702ac56..1cef0eda 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -61,7 +61,7 @@ pup [options] # Nested commands | containers | list, images (list) | src/commands/containers.rs | ✅ | | costs | datadog (projected, attribution, by-org, aws-config, azure-config, gcp-config), ccm (custom-costs, tag-descriptions, tag-metadata, tags, tag-keys, budgets, commitments) | src/commands/cost.rs, src/commands/cost_ccm.rs | ✅ | | product-analytics | events send | src/commands/product_analytics.rs | ✅ | -| profiling | profiles (list, download), services (list), profile-types (list), explore (flamegraph, callgraph) | src/commands/profiling.rs | ✅ | +| profiling | profiles (list, download), services (list), profile-types (list), explore (flamegraph, callgraph, timeline) | src/commands/profiling.rs | ✅ | | datasets | list, get, create, update, delete | src/commands/datasets.rs | ✅ | | data-deletion | requests (list, create, cancel) | src/commands/data_deletion.rs | ✅ | | data-governance | scanner-rules (list) | src/commands/data_governance.rs | ✅ | @@ -199,7 +199,7 @@ pup infrastructure hosts list - **infrastructure** - Host inventory (hosts list, hosts get) - **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) +- **profiling** - Continuous Profiler search/download (profiles list/download, services list, profile-types list, explore flamegraph/callgraph/timeline) ### Security & Compliance - **security** - Security monitoring (rules, signals, findings, content-packs, risk-scores) diff --git a/docs/EXAMPLES.md b/docs/EXAMPLES.md index f007cd82..8cb2c7d3 100644 --- a/docs/EXAMPLES.md +++ b/docs/EXAMPLES.md @@ -337,6 +337,26 @@ pup profiling explore callgraph \ --from="1h" --to="now" ``` +### Explore a Timeline +```bash +# Summarize a single profile's timeline (top lane groups by activity) +pup profiling explore timeline \ + --profile-id="prof-123" --event-id="AwAAAaB7..." + +# Recent timeline of one process, drilling into one lane group +# (use a "groupName" returned by a previous call) +pup profiling explore timeline \ + --runtime-id="0b1c2d3e-..." \ + --query="service:my-service" \ + --from="15m" --to="now" \ + --focus-lane-group="" + +# Timeline around a span, restricted to its critical path (Go only) +pup profiling explore timeline \ + --trace-id="trace-abc" --span-id="span-123" --time-hint="1700000000" \ + --critical-path +``` + ## Change Stories ### List Change Stories for a Service diff --git a/src/commands/profiling.rs b/src/commands/profiling.rs index d7c214dd..ca4ddc02 100644 --- a/src/commands/profiling.rs +++ b/src/commands/profiling.rs @@ -324,6 +324,95 @@ pub async fn explore_callgraph( formatter::output(cfg, &resp) } +#[allow(clippy::too_many_arguments)] +pub async fn explore_timeline( + cfg: &Config, + query: String, + from: String, + to: String, + runtime_id: Option, + trace_id: Option, + span_id: Option, + time_hint: Option, + profile_id: Option, + event_id: Option, + limit_lanes: Option, + focus_event_type: Option, + focus_from: Option, + focus_to: Option, + focus_lane_group: Option, + critical_path: bool, + extra_headers: &[(&str, &str)], +) -> Result<()> { + let scopes = [ + runtime_id.is_some(), + trace_id.is_some(), + profile_id.is_some(), + ]; + if scopes.iter().filter(|s| **s).count() != 1 { + anyhow::bail!( + "exactly one of --runtime-id, --trace-id, or --profile-id is required to scope the timeline" + ); + } + if profile_id.is_some() != event_id.is_some() { + anyhow::bail!("--profile-id and --event-id must be used together"); + } + if runtime_id.is_some() && query.trim().is_empty() { + anyhow::bail!("--query is required with --runtime-id"); + } + if critical_path && trace_id.is_none() { + anyhow::bail!("--critical-path requires --trace-id"); + } + if limit_lanes.is_some_and(|l| l <= 0) { + anyhow::bail!("--limit-lanes must be positive"); + } + 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(runtime_id) = runtime_id { + body["runtimeId"] = json!(runtime_id); + } + if let Some(trace_context) = trace_context { + body["traceContext"] = trace_context; + } + if let Some(profile_id) = profile_id { + body["profileContext"] = json!({ + "profileId": profile_id, + "eventId": event_id, + }); + } + if let Some(limit_lanes) = limit_lanes { + body["limitLanes"] = json!(limit_lanes); + } + if let Some(focus_event_type) = focus_event_type { + body["focusEventType"] = json!(focus_event_type); + } + if let Some(focus_from) = focus_from { + body["focusStartTime"] = json!(util_ext::parse_time_to_datetime(&focus_from)?.to_rfc3339()); + } + if let Some(focus_to) = focus_to { + body["focusEndTime"] = json!(util_ext::parse_time_to_datetime(&focus_to)?.to_rfc3339()); + } + if let Some(focus_lane_group) = focus_lane_group { + body["focusLaneGroup"] = json!(focus_lane_group); + } + if critical_path { + body["useCriticalPath"] = json!(true); + } + + let resp = raw_client::raw_post_with_headers( + cfg, + &format!("{BASE}/explore/timeline"), + body, + extra_headers, + ) + .await + .map_err(|e| anyhow::anyhow!("failed to explore timeline: {e:?}"))?; + formatter::output(cfg, &resp) +} + #[cfg(test)] mod tests { use crate::config::{Config, OutputFormat}; @@ -1832,4 +1921,354 @@ mod tests { cleanup_env(); std::env::remove_var("DD_TOKEN_STORAGE"); } + + // ---- explore timeline ---- + + const TIMELINE_RESPONSE: &str = r#"{"laneGroups":[],"timeRange":{"startNs":0,"endNs":0},"totalDuration":{"value":0.0,"unit":"nanoseconds","isPerMinute":false},"visualizationLink":{"title":"","url":""}}"#; + + /// Arguments for `explore_timeline`, defaulting to profile-id scoping. + struct TimelineArgs { + query: String, + runtime_id: Option, + trace_id: Option, + span_id: Option, + time_hint: Option, + profile_id: Option, + event_id: Option, + limit_lanes: Option, + focus_event_type: Option, + focus_from: Option, + focus_to: Option, + focus_lane_group: Option, + critical_path: bool, + } + + impl Default for TimelineArgs { + fn default() -> Self { + Self { + query: "".into(), + runtime_id: None, + trace_id: None, + span_id: None, + time_hint: None, + profile_id: Some("prof-123".into()), + event_id: Some("evt-456".into()), + limit_lanes: None, + focus_event_type: None, + focus_from: None, + focus_to: None, + focus_lane_group: None, + critical_path: false, + } + } + } + + impl TimelineArgs { + async fn run(self, cfg: &Config) -> anyhow::Result<()> { + super::explore_timeline( + cfg, + self.query, + "1h".into(), + "now".into(), + self.runtime_id, + self.trace_id, + self.span_id, + self.time_hint, + self.profile_id, + self.event_id, + self.limit_lanes, + self.focus_event_type, + self.focus_from, + self.focus_to, + self.focus_lane_group, + self.critical_path, + &[], + ) + .await + } + } + + async fn timeline_mock( + server: &mut mockito::ServerGuard, + expected_body: serde_json::Value, + ) -> mockito::Mock { + server + .mock("POST", "/api/unstable/profiling/pup/explore/timeline") + .match_body(mockito::Matcher::PartialJson(expected_body)) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(TIMELINE_RESPONSE) + .create_async() + .await + } + + async fn assert_timeline_validation_error(args: TimelineArgs, expected: &str) { + let _lock = lock_env().await; + std::env::set_var("DD_TOKEN_STORAGE", "file"); + let cfg = test_config("http://unused.local"); + + let result = args.run(&cfg).await; + let err = result.expect_err("expected a validation error"); + assert!( + err.to_string().contains(expected), + "expected error containing {expected:?}, got: {err}" + ); + + cleanup_env(); + std::env::remove_var("DD_TOKEN_STORAGE"); + } + + #[tokio::test] + async fn test_profiling_explore_timeline_profile_context_ok() { + 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 = timeline_mock( + &mut server, + serde_json::json!({"profileContext": {"profileId": "prof-123", "eventId": "evt-456"}}), + ) + .await; + + let result = TimelineArgs::default().run(&cfg).await; + assert!( + result.is_ok(), + "explore_timeline failed: {:?}", + result.err() + ); + mock.assert_async().await; + + cleanup_env(); + std::env::remove_var("DD_TOKEN_STORAGE"); + } + + #[tokio::test] + async fn test_profiling_explore_timeline_runtime_id_sends_optional_fields() { + 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 = timeline_mock( + &mut server, + serde_json::json!({ + "runtimeId": "rt-1", + "filter": {"query": "service:my-service"}, + "limitLanes": 3, + "focusEventType": "CPU", + "focusStartTime": "2023-11-14T22:13:20+00:00", + "focusEndTime": "2023-11-14T22:14:20+00:00", + "focusLaneGroup": "thread", + }), + ) + .await; + + let result = TimelineArgs { + query: "service:my-service".into(), + runtime_id: Some("rt-1".into()), + profile_id: None, + event_id: None, + limit_lanes: Some(3), + focus_event_type: Some("CPU".into()), + focus_from: Some("2023-11-14T22:13:20Z".into()), + focus_to: Some("1700000060".into()), + focus_lane_group: Some("thread".into()), + ..Default::default() + } + .run(&cfg) + .await; + assert!( + result.is_ok(), + "explore_timeline failed: {:?}", + result.err() + ); + mock.assert_async().await; + + cleanup_env(); + std::env::remove_var("DD_TOKEN_STORAGE"); + } + + #[tokio::test] + async fn test_profiling_explore_timeline_trace_context_with_critical_path() { + 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 = timeline_mock( + &mut server, + serde_json::json!({ + "traceContext": {"traceId": "trace-abc", "spanId": "span-123", "timeHint": "1700000000"}, + "useCriticalPath": true, + }), + ) + .await; + + let result = TimelineArgs { + trace_id: Some("trace-abc".into()), + span_id: Some("span-123".into()), + time_hint: Some("1700000000".into()), + profile_id: None, + event_id: None, + critical_path: true, + ..Default::default() + } + .run(&cfg) + .await; + assert!( + result.is_ok(), + "explore_timeline failed: {:?}", + result.err() + ); + mock.assert_async().await; + + cleanup_env(); + std::env::remove_var("DD_TOKEN_STORAGE"); + } + + #[tokio::test] + async fn test_profiling_explore_timeline_requires_scope() { + assert_timeline_validation_error( + TimelineArgs { + profile_id: None, + event_id: None, + ..Default::default() + }, + "exactly one of --runtime-id, --trace-id, or --profile-id", + ) + .await; + } + + #[tokio::test] + async fn test_profiling_explore_timeline_rejects_multiple_scopes() { + assert_timeline_validation_error( + TimelineArgs { + query: "service:my-service".into(), + runtime_id: Some("rt-1".into()), + ..Default::default() + }, + "exactly one of --runtime-id, --trace-id, or --profile-id", + ) + .await; + } + + #[tokio::test] + async fn test_profiling_explore_timeline_requires_profile_id_and_event_id_together() { + assert_timeline_validation_error( + TimelineArgs { + event_id: None, + ..Default::default() + }, + "--profile-id and --event-id must be used together", + ) + .await; + } + + #[tokio::test] + async fn test_profiling_explore_timeline_runtime_id_requires_query() { + assert_timeline_validation_error( + TimelineArgs { + runtime_id: Some("rt-1".into()), + profile_id: None, + event_id: None, + ..Default::default() + }, + "--query is required with --runtime-id", + ) + .await; + } + + #[tokio::test] + async fn test_profiling_explore_timeline_critical_path_requires_trace_id() { + assert_timeline_validation_error( + TimelineArgs { + critical_path: true, + ..Default::default() + }, + "--critical-path requires --trace-id", + ) + .await; + } + + #[tokio::test] + async fn test_profiling_explore_timeline_trace_id_requires_time_hint() { + assert_timeline_validation_error( + TimelineArgs { + trace_id: Some("trace-abc".into()), + span_id: Some("span-123".into()), + profile_id: None, + event_id: None, + ..Default::default() + }, + "--trace-id requires both --span-id and --time-hint", + ) + .await; + } + + #[tokio::test] + async fn test_profiling_explore_timeline_limit_lanes_must_be_positive() { + assert_timeline_validation_error( + TimelineArgs { + limit_lanes: Some(0), + ..Default::default() + }, + "--limit-lanes must be positive", + ) + .await; + } + + #[tokio::test] + async fn test_profiling_explore_timeline_tolerates_null_optional_fields() { + 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 body = r#"{"laneGroups":[],"timeRange":{"startNs":0,"endNs":0},"totalDuration":{"value":0.0,"unit":"nanoseconds","isPerMinute":false},"coverageRatio":null,"criticalPathMetadata":null,"visualizationLink":{"title":"","url":""},"emptyStateReason":null}"#; + let _mock = mock_any(&mut server, "POST", body).await; + + let result = TimelineArgs::default().run(&cfg).await; + assert!( + result.is_ok(), + "should tolerate null optional response fields: {:?}", + result.err() + ); + + cleanup_env(); + std::env::remove_var("DD_TOKEN_STORAGE"); + } + + #[tokio::test] + async fn test_profiling_explore_timeline_validation_error() { + 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", mockito::Matcher::Any) + .with_status(400) + .with_body( + r#"{"errors":["runtimeId, traceContext, or profileContext must be present"]}"#, + ) + .create_async() + .await; + + let result = TimelineArgs::default().run(&cfg).await; + assert!(result.is_err(), "expected a 400 to surface as an error"); + + cleanup_env(); + std::env::remove_var("DD_TOKEN_STORAGE"); + } + + #[tokio::test] + async fn test_profiling_explore_timeline_no_auth() { + let _lock = lock_env().await; + std::env::set_var("DD_TOKEN_STORAGE", "file"); + let cfg = no_auth_config(); + + let result = TimelineArgs::default().run(&cfg).await; + assert!(result.is_err(), "should fail without auth"); + + cleanup_env(); + std::env::remove_var("DD_TOKEN_STORAGE"); + } } diff --git a/src/main.rs b/src/main.rs index 813a926c..444a24e7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2353,6 +2353,7 @@ enum Commands { /// • List services and profile types with profiling data /// • Explore aggregated profiles as a flame graph / top stack traces /// • Explore aggregated profiles as a call graph + /// • Explore a profile's timeline (threads, lane groups, critical path) /// /// EXAMPLES: /// pup profiling profiles list --query "service:my-service" --from 1h @@ -2363,6 +2364,7 @@ enum Commands { /// --query "service:my-service" --from 1h /// pup profiling explore callgraph --profile-type cpu-time \ /// --query "service:my-service" --from 1h + /// pup profiling explore timeline --profile-id prof-123 --event-id evt-id-456 /// pup profiling --header "test-drive-hummer-aurora: 1" services list --from 1h /// /// AUTHENTICATION: @@ -4688,7 +4690,7 @@ enum ProfilingActions { #[command(subcommand)] action: ProfilingProfileTypesActions, }, - /// Explore aggregated profiling data (flame graphs, top stack traces) + /// Explore aggregated profiling data (flame graphs, call graphs, timelines) Explore { #[command(subcommand)] action: Box, @@ -4957,6 +4959,79 @@ enum ProfilingExploreActions { )] frame_filter: Option, }, + /// Summarize a profile timeline (lane groups, thread states, critical path) + Timeline { + #[arg( + long, + default_value = "", + help = "Filter query; required with --runtime-id" + )] + query: String, + #[arg( + long, + default_value = "1h", + help = "Start time: 1h, 5min, 2hours, RFC3339, Unix timestamp, or 'now'" + )] + from: String, + #[arg( + long, + default_value = "now", + help = "End time: 1h, 5min, 2hours, RFC3339, Unix timestamp, or 'now'" + )] + to: String, + #[arg( + long, + help = "Runtime ID of the process to scope the timeline to (requires --query)" + )] + runtime_id: Option, + #[arg(long, help = "Trace ID to scope the timeline to a span")] + trace_id: Option, + #[arg(long, help = "Span ID (required 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 timeline to (used together with --event-id)" + )] + profile_id: Option, + #[arg( + long, + help = "Event ID to scope the timeline (used together with --profile-id)" + )] + event_id: Option, + #[arg(long, help = "Max number of lane groups (or lanes) to return")] + limit_lanes: Option, + #[arg( + long, + help = "Rank lane groups by this event type (use a key from 'threadStates' or 'pointInTimeMarkers' in a previous response)" + )] + focus_event_type: Option, + #[arg( + long, + help = "Start of the focus window within the timeline (same formats as --from)" + )] + focus_from: Option, + #[arg( + long, + help = "End of the focus window within the timeline (same formats as --to)" + )] + focus_to: Option, + #[arg( + long, + help = "Drill into one lane group (its 'groupName' from a previous response), returning its individual lanes" + )] + focus_lane_group: Option, + #[arg( + long, + default_value_t = false, + help = "Restrict to the span's critical path (Go only; requires --trace-id)" + )] + critical_path: bool, + }, } // ---- Tags ---- @@ -19486,6 +19561,44 @@ async fn main_inner() -> anyhow::Result<()> { ) .await?; } + ProfilingExploreActions::Timeline { + query, + from, + to, + runtime_id, + trace_id, + span_id, + time_hint, + profile_id, + event_id, + limit_lanes, + focus_event_type, + focus_from, + focus_to, + focus_lane_group, + critical_path, + } => { + commands::profiling::explore_timeline( + &cfg, + query, + from, + to, + runtime_id, + trace_id, + span_id, + time_hint, + profile_id, + event_id, + limit_lanes, + focus_event_type, + focus_from, + focus_to, + focus_lane_group, + critical_path, + &extra_headers, + ) + .await?; + } }, } }