diff --git a/README.md b/README.md index 7f1fe5a..bbd0e51 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ list of commands as built. | RUM | ✅ | `rum apps`, `rum sessions`, `rum metrics`, `rum retention-filters`, `rum playlists`, `rum heatmaps` | Apps, sessions, metrics, retention filters, replay playlists, heatmaps | | APM Services | ✅ | `apm services`, `apm entities`, `apm dependencies`, `apm flow-map` | Services stats, operations, resources; entity queries; dependencies; flow visualization | | Traces | ✅ | `traces search`, `traces aggregate`, `traces metrics` | Span search/aggregation and span-based metric definitions | -| Profiling | ✅ | `profiling aggregate`, `profiling analytics`, `profiling timeline`, … | Continuous Profiler queries (requires API + App keys) | +| Profiling | ⏳ | `profiling` | Not supported in pup yet. Use the Datadog MCP server: https://docs.datadoghq.com/bits_ai/mcp_server. Enable with: https://mcp.datadoghq.com/api/unstable/mcp-server/mcp?toolsets=core,profiling | | Database Monitoring | ✅ | `dbm samples search` | DBM query sample search | | Session Replay | ❌ | - | Not yet implemented | diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index c5f6a13..2cb23e1 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 | aggregate, analysis, analytics, breakdown, callgraph, download, fields, info, list, save-favorite, timeline | src/commands/profiling.rs | ✅ | +| profiling | none | n/a | ⏳ | | 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 | ✅ | @@ -88,7 +88,7 @@ pup [options] # Nested commands **Auth note:** All workflow commands require `DD_API_KEY` + `DD_APP_KEY`. OAuth2 bearer tokens are not supported for workflow operations. -**Auth note (profiling):** All `pup profiling` commands require `DD_API_KEY` + `DD_APP_KEY`. No OAuth2 scope is declared for Continuous Profiler endpoints, so bearer tokens are not supported. +**Profiling note:** `pup profiling` has no subcommands yet. Use the Datadog MCP server instead: https://docs.datadoghq.com/bits_ai/mcp_server. Enable profiling in the MCP toolset with: https://mcp.datadoghq.com/api/unstable/mcp-server/mcp?toolsets=core,profiling ## Common Patterns @@ -157,7 +157,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 data (aggregate, analysis, analytics, breakdown, callgraph, download, fields, info, list, save-favorite, timeline) +- **profiling** - Placeholder that points users to the Datadog MCP server for profiler data ### Security & Compliance - **security** - Security monitoring (rules, signals, findings, content-packs, risk-scores) diff --git a/src/commands/cost_ccm.rs b/src/commands/cost_ccm.rs index e43c252..c6df9e9 100644 --- a/src/commands/cost_ccm.rs +++ b/src/commands/cost_ccm.rs @@ -839,321 +839,4 @@ mod tests { let _ = std::fs::remove_file(&tmp); cleanup_env(); } - - #[tokio::test] - async fn test_profiling_analytics_rejects_empty_group_by() { - let _lock = lock_env().await; - let s = mockito::Server::new_async().await; - let cfg = test_config(&s.url()); - // Mock not required: we should fail before hitting the API. - let result = crate::commands::profiling::analytics( - &cfg, - "*".into(), - "15m".into(), - "now".into(), - Some(" , ".into()), - None, - 100, - ) - .await; - assert!( - result.is_err(), - "expected error for empty --group-by tokens" - ); - cleanup_env(); - } - - #[tokio::test] - async fn test_profiling_fields_ok() { - let _lock = lock_env().await; - let mut s = mockito::Server::new_async().await; - let cfg = test_config(&s.url()); - let mock = s - .mock("POST", "/api/unstable/profiles/interactive-analytics/field") - .with_status(200) - .with_header("content-type", "application/json") - .with_body(r#"{"data":[]}"#) - .create_async() - .await; - let result = crate::commands::profiling::fields( - &cfg, - "service".into(), - "*".into(), - "15m".into(), - "now".into(), - 100, - ) - .await; - assert!(result.is_ok(), "fields failed: {:?}", result.err()); - mock.assert_async().await; - cleanup_env(); - } - - #[tokio::test] - async fn test_profiling_fields_error() { - let _lock = lock_env().await; - let mut s = mockito::Server::new_async().await; - let cfg = test_config(&s.url()); - s.mock("POST", mockito::Matcher::Any) - .with_status(500) - .create_async() - .await; - let result = crate::commands::profiling::fields( - &cfg, - "service".into(), - "*".into(), - "15m".into(), - "now".into(), - 100, - ) - .await; - assert!(result.is_err(), "expected error on 500"); - cleanup_env(); - } - - #[tokio::test] - async fn test_profiling_aggregate_ok() { - let _lock = lock_env().await; - let mut s = mockito::Server::new_async().await; - let cfg = test_config(&s.url()); - let mock = s - .mock("POST", "/profiling/api/v1/aggregate") - .with_status(200) - .with_header("content-type", "application/json") - .with_body(r#"{"flameGraph":[]}"#) - .create_async() - .await; - let result = crate::commands::profiling::aggregate( - &cfg, - "service:web".into(), - "cpu-time".into(), - "1h".into(), - "now".into(), - 100, - "sum".into(), - ) - .await; - assert!(result.is_ok(), "aggregate failed: {:?}", result.err()); - mock.assert_async().await; - cleanup_env(); - } - - #[tokio::test] - async fn test_profiling_aggregate_invalid_time() { - let _lock = lock_env().await; - let s = mockito::Server::new_async().await; - let cfg = test_config(&s.url()); - let result = crate::commands::profiling::aggregate( - &cfg, - "*".into(), - "cpu-time".into(), - "notatime".into(), - "now".into(), - 100, - "sum".into(), - ) - .await; - assert!(result.is_err(), "expected parse error on invalid --from"); - cleanup_env(); - } - - #[tokio::test] - async fn test_profiling_breakdown_ok_no_filter() { - let _lock = lock_env().await; - let mut s = mockito::Server::new_async().await; - let cfg = test_config(&s.url()); - let mock = s - .mock("POST", "/profiling/api/v1/profiles/pid/breakdown") - .with_status(200) - .with_header("content-type", "application/json") - .with_body(r#"{"tree":{}}"#) - .create_async() - .await; - let result = crate::commands::profiling::breakdown(&cfg, "pid", None, None, None).await; - assert!(result.is_ok(), "breakdown failed: {:?}", result.err()); - mock.assert_async().await; - cleanup_env(); - } - - #[tokio::test] - async fn test_profiling_breakdown_rejects_partial_filter() { - let _lock = lock_env().await; - let s = mockito::Server::new_async().await; - let cfg = test_config(&s.url()); - let result = crate::commands::profiling::breakdown( - &cfg, - "pid", - Some("service:web".into()), - Some("1h".into()), - None, - ) - .await; - assert!(result.is_err(), "expected error for partial filter triple"); - cleanup_env(); - } - - #[tokio::test] - async fn test_profiling_timeline_ok() { - let _lock = lock_env().await; - let mut s = mockito::Server::new_async().await; - let cfg = test_config(&s.url()); - let mock = s - .mock("POST", "/profiling/api/v1/profiles/pid/timeline") - .with_status(200) - .with_header("content-type", "application/json") - .with_body(r#"{"layers":[]}"#) - .create_async() - .await; - let result = crate::commands::profiling::timeline(&cfg, "pid", "eid").await; - assert!(result.is_ok(), "timeline failed: {:?}", result.err()); - mock.assert_async().await; - cleanup_env(); - } - - #[tokio::test] - async fn test_profiling_timeline_error() { - let _lock = lock_env().await; - let mut s = mockito::Server::new_async().await; - let cfg = test_config(&s.url()); - s.mock("POST", mockito::Matcher::Any) - .with_status(404) - .create_async() - .await; - let result = crate::commands::profiling::timeline(&cfg, "missing", "eid").await; - assert!(result.is_err(), "expected error on 404"); - cleanup_env(); - } - - #[tokio::test] - async fn test_profiling_callgraph_ok() { - let _lock = lock_env().await; - let mut s = mockito::Server::new_async().await; - let cfg = test_config(&s.url()); - let mock = s - .mock("POST", "/api/unstable/profiles/callgraph") - .with_status(200) - .with_header("content-type", "application/json") - .with_body(r#"{"nodes":[]}"#) - .create_async() - .await; - let result = crate::commands::profiling::callgraph( - &cfg, - "service:web".into(), - "cpu-time".into(), - "15m".into(), - "now".into(), - 100, - ) - .await; - assert!(result.is_ok(), "callgraph failed: {:?}", result.err()); - mock.assert_async().await; - cleanup_env(); - } - - #[tokio::test] - async fn test_profiling_callgraph_error() { - let _lock = lock_env().await; - let mut s = mockito::Server::new_async().await; - let cfg = test_config(&s.url()); - s.mock("POST", mockito::Matcher::Any) - .with_status(500) - .create_async() - .await; - let result = crate::commands::profiling::callgraph( - &cfg, - "*".into(), - "cpu-time".into(), - "15m".into(), - "now".into(), - 100, - ) - .await; - assert!(result.is_err(), "expected error on 500"); - cleanup_env(); - } - - #[tokio::test] - async fn test_profiling_save_favorite_ok() { - let _lock = lock_env().await; - let mut s = mockito::Server::new_async().await; - let cfg = test_config(&s.url()); - let mock = s - .mock("POST", "/api/unstable/profiles/save-favorite") - .with_status(200) - .with_header("content-type", "application/json") - .with_body(r#"{"queryId":"abc"}"#) - .create_async() - .await; - let result = crate::commands::profiling::save_favorite( - &cfg, - "service:web".into(), - "15m".into(), - "now".into(), - "fav-1".into(), - 100, - ) - .await; - assert!(result.is_ok(), "save_favorite failed: {:?}", result.err()); - mock.assert_async().await; - cleanup_env(); - } - - #[tokio::test] - async fn test_profiling_save_favorite_error() { - let _lock = lock_env().await; - let mut s = mockito::Server::new_async().await; - let cfg = test_config(&s.url()); - s.mock("POST", mockito::Matcher::Any) - .with_status(500) - .create_async() - .await; - let result = crate::commands::profiling::save_favorite( - &cfg, - "*".into(), - "15m".into(), - "now".into(), - "fav-1".into(), - 100, - ) - .await; - assert!(result.is_err(), "expected error on 500"); - cleanup_env(); - } - - #[tokio::test] - async fn test_profiling_download_to_file() { - let _lock = lock_env().await; - let mut s = mockito::Server::new_async().await; - let cfg = test_config(&s.url()); - let mock = s - .mock("GET", "/api/ui/profiling/profiles/eid/download") - .with_status(200) - .with_header("content-type", "application/octet-stream") - .with_body(b"profile-bytes") - .create_async() - .await; - let tmp = std::env::temp_dir().join(format!("pup-prof-{}.bin", std::process::id())); - let tmp_str = tmp.to_string_lossy().to_string(); - let result = crate::commands::profiling::download(&cfg, "eid", Some(tmp_str.clone())).await; - assert!(result.is_ok(), "download failed: {:?}", result.err()); - let contents = std::fs::read(&tmp).expect("output file"); - assert_eq!(contents, b"profile-bytes"); - let _ = std::fs::remove_file(&tmp); - mock.assert_async().await; - cleanup_env(); - } - - #[tokio::test] - async fn test_profiling_download_error() { - let _lock = lock_env().await; - let mut s = mockito::Server::new_async().await; - let cfg = test_config(&s.url()); - s.mock("GET", mockito::Matcher::Any) - .with_status(404) - .create_async() - .await; - let result = crate::commands::profiling::download(&cfg, "missing-eid", None).await; - assert!(result.is_err(), "expected error on 404"); - cleanup_env(); - } } diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 329224e..d159d01 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -61,7 +61,6 @@ pub mod on_call; pub mod organizations; pub mod processes; pub mod product_analytics; -pub mod profiling; pub mod reference_tables; pub mod rum; #[cfg(not(target_arch = "wasm32"))] diff --git a/src/commands/profiling.rs b/src/commands/profiling.rs deleted file mode 100644 index 3c45439..0000000 --- a/src/commands/profiling.rs +++ /dev/null @@ -1,497 +0,0 @@ -use anyhow::Result; -use serde_json::json; - -use crate::client; -use crate::config::Config; -use crate::formatter; -use crate::util; - -fn parse_window(from: &str, to: &str) -> Result<(String, String)> { - let from_ms = util::parse_time_to_unix_millis(from) - .map_err(|e| anyhow::anyhow!("invalid --from value: {e}"))?; - let to_ms = util::parse_time_to_unix_millis(to) - .map_err(|e| anyhow::anyhow!("invalid --to value: {e}"))?; - let from_iso = chrono::DateTime::from_timestamp_millis(from_ms) - .ok_or_else(|| { - anyhow::anyhow!("--from {from:?} resolved to {from_ms} ms which is outside the representable date range") - })? - .to_rfc3339(); - let to_iso = chrono::DateTime::from_timestamp_millis(to_ms) - .ok_or_else(|| { - anyhow::anyhow!( - "--to {to:?} resolved to {to_ms} ms which is outside the representable date range" - ) - })? - .to_rfc3339(); - Ok((from_iso, to_iso)) -} - -fn filter_body(query: &str, from: &str, to: &str) -> Result { - let (from_iso, to_iso) = parse_window(from, to)?; - Ok(json!({ - "filter": { "from": from_iso, "to": to_iso, "query": query }, - })) -} - -fn split_csv(flag: &str, value: Option) -> Result> { - let Some(raw) = value else { - return Ok(Vec::new()); - }; - let parts: Vec = raw - .split(',') - .map(|p| p.trim().to_string()) - .filter(|p| !p.is_empty()) - .collect(); - if parts.is_empty() { - anyhow::bail!("{flag} was provided but contained no non-empty values: {raw:?}"); - } - Ok(parts) -} - -#[allow(clippy::too_many_arguments)] -pub async fn aggregate( - cfg: &Config, - query: String, - profile_type: String, - from: String, - to: String, - limit: u32, - aggregation_function: String, -) -> Result<()> { - let (from_iso, to_iso) = parse_window(&from, &to)?; - // /profiling/api/v1/aggregate expects a flat body — query/from/to are siblings, not wrapped in filter{}. - let body = json!({ - "profileType": profile_type, - "query": query, - "from": from_iso, - "to": to_iso, - "limit": limit, - "aggregationFunction": aggregation_function, - }); - let resp = client::raw_post(cfg, "/profiling/api/v1/aggregate", body) - .await - .map_err(|e| anyhow::anyhow!("failed to aggregate profiles: {e:?}"))?; - formatter::output(cfg, &resp) -} - -pub async fn analysis(cfg: &Config, profile_id: &str, event_id: Option) -> Result<()> { - let path = format!("/profiling/api/v1/profiles/{profile_id}/analysis"); - let query: Vec<(&str, &str)> = match event_id.as_deref() { - Some(eid) => vec![("eventId", eid)], - None => vec![], - }; - let resp = client::raw_get(cfg, &path, &query) - .await - .map_err(|e| anyhow::anyhow!("failed to get profile analysis: {e:?}"))?; - formatter::output(cfg, &resp) -} - -#[allow(clippy::too_many_arguments)] -pub async fn analytics( - cfg: &Config, - query: String, - from: String, - to: String, - group_by: Option, - compute: Option, - limit: u32, -) -> Result<()> { - let mut body = filter_body(&query, &from, &to)?; - body["limit"] = json!(limit); - let groups = split_csv("--group-by", group_by)?; - if !groups.is_empty() { - body["groupBy"] = json!(groups); - } - let computes = split_csv("--compute", compute)?; - if !computes.is_empty() { - body["compute"] = json!(computes); - } - let resp = client::raw_post(cfg, "/api/unstable/profiles/analytics", body) - .await - .map_err(|e| anyhow::anyhow!("failed to run profiling analytics: {e:?}"))?; - formatter::output(cfg, &resp) -} - -pub async fn breakdown( - cfg: &Config, - profile_id: &str, - query: Option, - from: Option, - to: Option, -) -> Result<()> { - let mut body = json!({ "profileIds": [profile_id] }); - match (query.as_ref(), from.as_ref(), to.as_ref()) { - (Some(q), Some(f), Some(t)) => { - let (from_iso, to_iso) = parse_window(f, t)?; - body["filter"] = json!({ "from": from_iso, "to": to_iso, "query": q }); - } - (None, None, None) => {} - _ => { - anyhow::bail!("--query, --from, and --to must all be provided together, or all omitted") - } - } - let path = format!("/profiling/api/v1/profiles/{profile_id}/breakdown"); - let resp = client::raw_post(cfg, &path, body) - .await - .map_err(|e| anyhow::anyhow!("failed to compute profile breakdown: {e:?}"))?; - formatter::output(cfg, &resp) -} - -pub async fn callgraph( - cfg: &Config, - query: String, - profile_type: String, - from: String, - to: String, - limit: u32, -) -> Result<()> { - let mut body = filter_body(&query, &from, &to)?; - body["profileType"] = json!(profile_type); - body["limit"] = json!(limit); - let resp = client::raw_post(cfg, "/api/unstable/profiles/callgraph", body) - .await - .map_err(|e| anyhow::anyhow!("failed to load call graph: {e:?}"))?; - formatter::output(cfg, &resp) -} - -pub async fn download(cfg: &Config, event_id: &str, output: Option) -> Result<()> { - use std::io::Write; - // The path segment is named "profiles/", but the ID is the profile event ID - // (the `id` field on a `pup profiling list` result), not `attributes.profile-id`. - let url_path = format!("/api/ui/profiling/profiles/{event_id}/download"); - let resp = client::raw_request( - cfg, - "GET", - &url_path, - &[], - None, - None, - "application/octet-stream", - &[], - ) - .await - .map_err(|e| anyhow::anyhow!("failed to download profile: {e:?}"))?; - - match output { - Some(out_path) => { - let mut f = std::fs::File::create(&out_path) - .map_err(|e| anyhow::anyhow!("failed to create {out_path}: {e}"))?; - f.write_all(&resp.bytes) - .map_err(|e| anyhow::anyhow!("failed to write {out_path}: {e}"))?; - f.sync_all() - .map_err(|e| anyhow::anyhow!("failed to flush {out_path} to disk: {e}"))?; - eprintln!("Wrote {} bytes to {}", resp.bytes.len(), out_path); - } - None => { - let mut out = std::io::stdout().lock(); - out.write_all(&resp.bytes) - .map_err(|e| anyhow::anyhow!("failed to write to stdout: {e}"))?; - out.flush() - .map_err(|e| anyhow::anyhow!("failed to flush stdout: {e}"))?; - } - } - Ok(()) -} - -pub async fn fields( - cfg: &Config, - field: String, - query: String, - from: String, - to: String, - limit: u32, -) -> Result<()> { - let mut body = filter_body(&query, &from, &to)?; - body["fieldName"] = json!(field); - body["limit"] = json!(limit); - let resp = client::raw_post( - cfg, - "/api/unstable/profiles/interactive-analytics/field", - body, - ) - .await - .map_err(|e| anyhow::anyhow!("failed to list field values: {e:?}"))?; - formatter::output(cfg, &resp) -} - -pub async fn info(cfg: &Config, profile_id: &str, event_id: Option) -> Result<()> { - let path = format!("/profiling/api/v1/profiles/{profile_id}/info"); - let query: Vec<(&str, &str)> = match event_id.as_deref() { - Some(eid) => vec![("eventId", eid)], - None => vec![], - }; - let resp = client::raw_get(cfg, &path, &query) - .await - .map_err(|e| anyhow::anyhow!("failed to get profile info: {e:?}"))?; - formatter::output(cfg, &resp) -} - -pub async fn list( - cfg: &Config, - query: String, - from: String, - to: String, - sort_field: Option, - sort_order: String, - limit: u32, -) -> Result<()> { - let mut body = filter_body(&query, &from, &to)?; - body["limit"] = json!(limit); - if let Some(field) = sort_field { - body["sort"] = json!({ "field": field, "order": sort_order }); - } - let resp = client::raw_post(cfg, "/api/unstable/profiles/list", body) - .await - .map_err(|e| anyhow::anyhow!("failed to list profiles: {e:?}"))?; - formatter::output(cfg, &resp) -} - -pub async fn save_favorite( - cfg: &Config, - query: String, - from: String, - to: String, - query_id: String, - limit: u32, -) -> Result<()> { - let mut body = filter_body(&query, &from, &to)?; - body["queryId"] = json!(query_id); - body["limit"] = json!(limit); - let resp = client::raw_post(cfg, "/api/unstable/profiles/save-favorite", body) - .await - .map_err(|e| anyhow::anyhow!("failed to save favorite: {e:?}"))?; - formatter::output(cfg, &resp) -} - -pub async fn timeline(cfg: &Config, profile_id: &str, event_id: &str) -> Result<()> { - // TimelineRequest DTO uses kebab-case JSON keys and requires both profile-ids and event-ids. - let body = json!({ - "profile-ids": [profile_id], - "event-ids": [event_id], - "archivalContext": "", - }); - let path = format!("/profiling/api/v1/profiles/{profile_id}/timeline"); - let resp = client::raw_post(cfg, &path, body) - .await - .map_err(|e| anyhow::anyhow!("failed to load profile timeline: {e:?}"))?; - formatter::output(cfg, &resp) -} - -#[cfg(test)] -mod tests { - - use crate::test_support::*; - - #[tokio::test] - async fn test_profiling_list_ok() { - let _lock = lock_env().await; - let mut s = mockito::Server::new_async().await; - let cfg = test_config(&s.url()); - let mock = s - .mock("POST", "/api/unstable/profiles/list") - .with_status(200) - .with_header("content-type", "application/json") - .with_body(r#"{"data":[]}"#) - .create_async() - .await; - let result = super::list( - &cfg, - "*".into(), - "15m".into(), - "now".into(), - None, - "desc".into(), - 100, - ) - .await; - assert!(result.is_ok(), "list failed: {:?}", result.err()); - mock.assert_async().await; - cleanup_env(); - } - - #[tokio::test] - async fn test_profiling_list_error() { - let _lock = lock_env().await; - let mut s = mockito::Server::new_async().await; - let cfg = test_config(&s.url()); - s.mock("POST", "/api/unstable/profiles/list") - .with_status(500) - .with_body(r#"{"errors":["boom"]}"#) - .create_async() - .await; - let result = super::list( - &cfg, - "*".into(), - "15m".into(), - "now".into(), - None, - "desc".into(), - 100, - ) - .await; - assert!(result.is_err(), "expected error on 500"); - cleanup_env(); - } - - #[tokio::test] - async fn test_tag_desc_upsert_with_cloud_success() { - let _lock = lock_env().await; - let mut server = mockito::Server::new_async().await; - let cfg = test_config(&server.url()); - let _mock = mock_any(&mut server, "PUT", r#"{}"#).await; - let result = crate::commands::cost_ccm::tag_desc_upsert( - &cfg, - "team", - "The team tag", - Some("aws".into()), - ) - .await; - assert!( - result.is_ok(), - "tag_desc_upsert with cloud failed: {:?}", - result.err() - ); - cleanup_env(); - } - - #[tokio::test] - async fn test_profiling_info_ok() { - let _lock = lock_env().await; - let mut s = mockito::Server::new_async().await; - let cfg = test_config(&s.url()); - let mock = s - .mock("GET", "/profiling/api/v1/profiles/abc123/info") - .with_status(200) - .with_header("content-type", "application/json") - .with_body(r#"{"data":{"id":"abc123"}}"#) - .create_async() - .await; - let result = super::info(&cfg, "abc123", None).await; - assert!(result.is_ok(), "info failed: {:?}", result.err()); - mock.assert_async().await; - cleanup_env(); - } - - #[tokio::test] - async fn test_profiling_info_with_event_id() { - let _lock = lock_env().await; - let mut s = mockito::Server::new_async().await; - let cfg = test_config(&s.url()); - let mock = s - .mock("GET", "/profiling/api/v1/profiles/abc123/info") - .match_query(mockito::Matcher::UrlEncoded( - "eventId".into(), - "evt-1".into(), - )) - .with_status(200) - .with_header("content-type", "application/json") - .with_body(r#"{"data":{"id":"abc123"}}"#) - .create_async() - .await; - let result = super::info(&cfg, "abc123", Some("evt-1".into())).await; - assert!(result.is_ok(), "info failed: {:?}", result.err()); - mock.assert_async().await; - cleanup_env(); - } - - #[tokio::test] - async fn test_profiling_info_error() { - let _lock = lock_env().await; - let mut s = mockito::Server::new_async().await; - let cfg = test_config(&s.url()); - s.mock("GET", mockito::Matcher::Any) - .with_status(404) - .with_body(r#"{"errors":["not found"]}"#) - .create_async() - .await; - let result = super::info(&cfg, "missing", None).await; - assert!(result.is_err(), "expected error on 404"); - cleanup_env(); - } - - #[tokio::test] - async fn test_profiling_analysis_ok() { - let _lock = lock_env().await; - let mut s = mockito::Server::new_async().await; - let cfg = test_config(&s.url()); - let mock = s - .mock("GET", "/profiling/api/v1/profiles/abc/analysis") - .with_status(200) - .with_header("content-type", "application/json") - .with_body(r#"{"data":{"insights":[]}}"#) - .create_async() - .await; - let result = super::analysis(&cfg, "abc", None).await; - assert!(result.is_ok(), "analysis failed: {:?}", result.err()); - mock.assert_async().await; - cleanup_env(); - } - - #[tokio::test] - async fn test_profiling_analysis_error() { - let _lock = lock_env().await; - let mut s = mockito::Server::new_async().await; - let cfg = test_config(&s.url()); - s.mock("GET", mockito::Matcher::Any) - .with_status(500) - .create_async() - .await; - let result = super::analysis(&cfg, "abc", None).await; - assert!(result.is_err(), "expected error on 500"); - cleanup_env(); - } - - #[tokio::test] - async fn test_profiling_analytics_ok() { - let _lock = lock_env().await; - let mut s = mockito::Server::new_async().await; - let cfg = test_config(&s.url()); - let mock = s - .mock("POST", "/api/unstable/profiles/analytics") - .with_status(200) - .with_header("content-type", "application/json") - .with_body(r#"{"data":[]}"#) - .create_async() - .await; - let result = super::analytics( - &cfg, - "service:web".into(), - "15m".into(), - "now".into(), - Some("service,env".into()), - Some("count".into()), - 100, - ) - .await; - assert!(result.is_ok(), "analytics failed: {:?}", result.err()); - mock.assert_async().await; - cleanup_env(); - } - - #[tokio::test] - async fn test_tag_desc_delete_success() { - let _lock = lock_env().await; - let mut server = mockito::Server::new_async().await; - let cfg = test_config(&server.url()); - let _mock = mock_any(&mut server, "DELETE", r#"{}"#).await; - let result = crate::commands::cost_ccm::tag_desc_delete(&cfg, "team", None).await; - assert!(result.is_ok(), "tag_desc_delete failed: {:?}", result.err()); - cleanup_env(); - } - - #[tokio::test] - async fn test_tag_desc_delete_with_cloud_success() { - let _lock = lock_env().await; - let mut server = mockito::Server::new_async().await; - let cfg = test_config(&server.url()); - let _mock = mock_any(&mut server, "DELETE", r#"{}"#).await; - let result = - crate::commands::cost_ccm::tag_desc_delete(&cfg, "team", Some("azure".into())).await; - assert!( - result.is_ok(), - "tag_desc_delete with cloud failed: {:?}", - result.err() - ); - cleanup_env(); - } -} diff --git a/src/main.rs b/src/main.rs index e40bf27..3ab8a2b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2092,35 +2092,15 @@ enum Commands { #[command(subcommand)] action: ProductAnalyticsActions, }, - /// Query and download continuous profiler data + /// Datadog Continuous Profiler (not supported in pup yet) /// - /// Search, analyze, and download data from the Datadog Continuous Profiler. + /// Profiling is not supported in pup yet. Use the Datadog MCP server instead: + /// https://docs.datadoghq.com/bits_ai/mcp_server /// - /// CAPABILITIES: - /// • List profiles with flexible query + time windows - /// • Get profile metadata (info) and automatic analysis - /// • Run analytics aggregations (groupBy / compute) - /// • Enumerate interactive field values - /// • Aggregate flamegraph, breakdown, timeline, and call graph data as JSON - /// • Save favorite queries - /// • Download raw profile archives - /// - /// EXAMPLES: - /// pup profiling list --query="service:web env:prod" --from=1h - /// pup profiling info - /// pup profiling analytics --query="service:web" --group-by=service --compute=count - /// pup profiling aggregate --query="service:web" --profile-type=cpu-time --from=1h - /// pup profiling download -o profile.zip - /// - /// AUTHENTICATION: - /// Requires DD_API_KEY + DD_APP_KEY. OAuth2 bearer tokens are not - /// supported for Continuous Profiler endpoints — no OAuth scope is - /// declared for them. + /// Enable profiling in the MCP toolset with: + /// https://mcp.datadoghq.com/api/unstable/mcp-server/mcp?toolsets=core,profiling #[command(verbatim_doc_comment)] - Profiling { - #[command(subcommand)] - action: ProfilingActions, - }, + Profiling, /// Manage reference tables for log enrichment /// /// Reference tables allow you to enrich logs with additional data from @@ -8895,163 +8875,6 @@ enum ProductAnalyticsEventActions { }, } -// ---- Profiling ---- -#[derive(Subcommand)] -enum ProfilingActions { - /// Aggregate flamegraph across matching profiles - Aggregate { - #[arg(long, default_value = "*", help = "Profile search query")] - query: String, - #[arg( - long, - default_value = "cpu-time", - help = "Profile type (e.g. cpu-time, wall-time)" - )] - profile_type: String, - #[arg(long, default_value = "15m", help = "Start time")] - from: String, - #[arg(long, default_value = "now", help = "End time")] - to: String, - #[arg(long, default_value = "100", help = "Maximum profiles to aggregate")] - limit: u32, - #[arg(long, default_value = "sum", help = "Aggregation function: sum or avg")] - aggregation_function: String, - }, - /// Get automated analysis for a profile - Analysis { - #[arg(help = "Profile ID")] - profile_id: String, - #[arg(long, help = "Event ID scope (optional)")] - event_id: Option, - }, - /// Run profiling analytics with groupBy/compute - Analytics { - #[arg(long, default_value = "*", help = "Profile search query")] - query: String, - #[arg(long, default_value = "15m", help = "Start time")] - from: String, - #[arg(long, default_value = "now", help = "End time")] - to: String, - #[arg(long, help = "Comma-separated group-by fields (e.g. service,env)")] - group_by: Option, - #[arg( - long, - help = "Comma-separated compute expressions (e.g. count,sum:duration)" - )] - compute: Option, - #[arg(long, default_value = "100", help = "Maximum rows to return")] - limit: u32, - }, - /// Compute a breakdown for a single profile (JSON output) - Breakdown { - #[arg(help = "Profile ID")] - profile_id: String, - #[arg( - long, - help = "Optional search query (must be used with --from and --to)" - )] - query: Option, - #[arg( - long, - help = "Optional start time (must be used with --query and --to)" - )] - from: Option, - #[arg( - long, - help = "Optional end time (must be used with --query and --from)" - )] - to: Option, - }, - /// Compute a call graph (JSON output) - Callgraph { - #[arg(long, default_value = "*", help = "Profile search query")] - query: String, - #[arg(long, default_value = "cpu-time", help = "Profile type")] - profile_type: String, - #[arg(long, default_value = "15m", help = "Start time")] - from: String, - #[arg(long, default_value = "now", help = "End time")] - to: String, - #[arg(long, default_value = "100", help = "Node limit")] - limit: u32, - }, - /// Download a profile archive - Download { - #[arg(help = "Profile event ID (the `id` field from `pup profiling list`)")] - event_id: String, - #[arg(short = 'o', long, help = "Output path (writes to stdout if omitted)")] - output: Option, - }, - /// Enumerate values for a field - Fields { - #[arg(long, help = "Field name (required)")] - field: String, - #[arg(long, default_value = "*", help = "Profile search query")] - query: String, - #[arg(long, default_value = "15m", help = "Start time")] - from: String, - #[arg(long, default_value = "now", help = "End time")] - to: String, - #[arg(long, default_value = "100", help = "Maximum values to return")] - limit: u32, - }, - /// Get profile metadata - Info { - #[arg(help = "Profile ID")] - profile_id: String, - #[arg(long, help = "Event ID scope (optional)")] - event_id: Option, - }, - /// List profiles matching a query - List { - #[arg(long, default_value = "*", help = "Profile search query")] - query: String, - #[arg( - long, - default_value = "15m", - 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 = "Field to sort by (e.g. 'start', 'duration')")] - sort_field: Option, - #[arg(long, default_value = "desc", help = "Sort order: asc or desc")] - sort_order: String, - #[arg(long, default_value = "100", help = "Maximum profiles to return")] - limit: u32, - }, - /// Save a query as a favorite - #[command(name = "save-favorite")] - SaveFavorite { - #[arg(long, help = "Query ID (required)")] - query_id: String, - #[arg(long, help = "Profile search query")] - query: String, - #[arg(long, default_value = "15m", help = "Start time")] - from: String, - #[arg(long, default_value = "now", help = "End time")] - to: String, - #[arg( - long, - default_value = "100", - help = "Default limit for the saved query" - )] - limit: u32, - }, - /// Fetch the timeline for a single profile (JSON output) - Timeline { - #[arg(help = "Profile ID")] - profile_id: String, - #[arg(long, help = "Profile event ID (required)")] - event_id: String, - }, -} - // ---- Static Analysis ---- #[derive(Subcommand)] enum StaticAnalysisActions { @@ -14333,109 +14156,10 @@ async fn main_inner() -> anyhow::Result<()> { } } // --- Profiling --- - Commands::Profiling { action } => { - cfg.validate_auth()?; - match action { - ProfilingActions::Aggregate { - query, - profile_type, - from, - to, - limit, - aggregation_function, - } => { - commands::profiling::aggregate( - &cfg, - query, - profile_type, - from, - to, - limit, - aggregation_function, - ) - .await?; - } - ProfilingActions::Analysis { - profile_id, - event_id, - } => { - commands::profiling::analysis(&cfg, &profile_id, event_id).await?; - } - ProfilingActions::Analytics { - query, - from, - to, - group_by, - compute, - limit, - } => { - commands::profiling::analytics(&cfg, query, from, to, group_by, compute, limit) - .await?; - } - ProfilingActions::Breakdown { - profile_id, - query, - from, - to, - } => { - commands::profiling::breakdown(&cfg, &profile_id, query, from, to).await?; - } - ProfilingActions::Callgraph { - query, - profile_type, - from, - to, - limit, - } => { - commands::profiling::callgraph(&cfg, query, profile_type, from, to, limit) - .await?; - } - ProfilingActions::Download { event_id, output } => { - commands::profiling::download(&cfg, &event_id, output).await?; - } - ProfilingActions::Fields { - field, - query, - from, - to, - limit, - } => { - commands::profiling::fields(&cfg, field, query, from, to, limit).await?; - } - ProfilingActions::Info { - profile_id, - event_id, - } => { - commands::profiling::info(&cfg, &profile_id, event_id).await?; - } - ProfilingActions::List { - query, - from, - to, - sort_field, - sort_order, - limit, - } => { - commands::profiling::list(&cfg, query, from, to, sort_field, sort_order, limit) - .await?; - } - ProfilingActions::SaveFavorite { - query_id, - query, - from, - to, - limit, - } => { - commands::profiling::save_favorite(&cfg, query, from, to, query_id, limit) - .await?; - } - ProfilingActions::Timeline { - profile_id, - event_id, - } => { - commands::profiling::timeline(&cfg, &profile_id, &event_id).await?; - } - } + Commands::Profiling => { + println!( + "Profiling is not supported in pup yet. Use the Datadog MCP server instead:\n\nDocs: https://docs.datadoghq.com/bits_ai/mcp_server\nEnable profiling toolset: https://mcp.datadoghq.com/api/unstable/mcp-server/mcp?toolsets=core,profiling" + ); } // --- Reference Tables --- Commands::ReferenceTables { action } => {