Skip to content

Commit f3d3d80

Browse files
authored
feat(telemetry)!: Add Installation signature and AppProduct changes payloads (#2213)
And some smaller stuff like more metric namespaces. Adding endpoint batching as well. This bridges the gap to the current capabilities exposed by dd-trace-py. Also contains a small debugger change to abort on timeout. Co-authored-by: bob.weinand <bob.weinand@datadoghq.com>
1 parent 01f18d5 commit f3d3d80

23 files changed

Lines changed: 1333 additions & 116 deletions

File tree

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

datadog-live-debugger/src/sender.rs

Lines changed: 128 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -305,12 +305,22 @@ pub fn generate_tags(
305305
percent_encode(tags.as_bytes(), CONTROLS).to_string()
306306
}
307307

308+
/// Owns the spawned request task and aborts it when dropped.
309+
struct AbortOnDrop(JoinHandle<anyhow::Result<http::Response<Bytes>>>);
310+
311+
impl Drop for AbortOnDrop {
312+
fn drop(&mut self) {
313+
// A no-op once the task has completed, so the success path is unaffected.
314+
self.0.abort();
315+
}
316+
}
317+
308318
#[derive(Default)]
309319
enum SenderFuture {
310320
#[default]
311321
Error,
312322
Outstanding(ResponseFuture),
313-
Submitted(JoinHandle<anyhow::Result<http::Response<Bytes>>>),
323+
Submitted(AbortOnDrop),
314324
}
315325

316326
pub struct PayloadSender {
@@ -322,7 +332,7 @@ pub struct PayloadSender {
322332
}
323333

324334
const BOUNDARY: &str = "------------------------44617461646f67";
325-
const BOUNDARY_LINE: &str = concat!("--", BOUNDARY, "\r\n");
335+
const BOUNDARY_LINE: &str = concat!("--", BOUNDARY);
326336

327337
impl PayloadSender {
328338
pub fn new(
@@ -426,18 +436,19 @@ impl PayloadSender {
426436
SenderFuture::Outstanding(future) => {
427437
if self.needs_boundary {
428438
let header = concat!(
429-
BOUNDARY_LINE,
430-
"Content-Disposition: form-data; name=\"event\"; filename=\"event.json\"\r\n",
431-
"Content-Type: application/json\r\n",
432-
"\r\n",
439+
BOUNDARY_LINE,
440+
"\r\n",
441+
"Content-Disposition: form-data; name=\"event\"; filename=\"event.json\"\r\n",
442+
"Content-Type: application/json\r\n",
443+
"\r\n",
433444
);
434445
self.sender.send_chunk(header.into()).await?;
435446
}
436447

437-
self.future = SenderFuture::Submitted(tokio::spawn(async move {
448+
self.future = SenderFuture::Submitted(AbortOnDrop(tokio::spawn(async move {
438449
let resp = future.await?;
439450
Ok(resp)
440-
}));
451+
})));
441452
true
442453
}
443454
future => {
@@ -463,7 +474,7 @@ impl PayloadSender {
463474
// insert a trailing ]
464475
if self.needs_boundary {
465476
self.sender
466-
.send_chunk(concat!("]\r\n", BOUNDARY_LINE).into())
477+
.send_chunk(concat!("]\r\n", BOUNDARY_LINE, "--\r\n").into())
467478
.await?;
468479
} else {
469480
self.sender.send_chunk(Bytes::from_static(b"]")).await?;
@@ -472,17 +483,14 @@ impl PayloadSender {
472483
drop(self.sender);
473484
// Once the body is fully sent, bound the wait for the response headers and (if
474485
// needed) the response body under a single timeout - a slow/stalled server must
475-
// not be able to hang this indefinitely. Abort the spawned task on timeout so the
476-
// underlying request is actually cancelled instead of left running detached.
486+
// not be able to hang this indefinitely. Returning here drops `future`, whose
487+
// `AbortOnDrop` cancels the underlying request rather than detaching it.
477488
let response =
478-
match tokio::time::timeout(Duration::from_millis(self.timeout_ms), &mut future)
489+
match tokio::time::timeout(Duration::from_millis(self.timeout_ms), &mut future.0)
479490
.await
480491
{
481492
Ok(joined) => joined??,
482-
Err(_) => {
483-
future.abort();
484-
return Err(anyhow::anyhow!("debugger payload request timed out"));
485-
}
493+
Err(_) => return Err(anyhow::anyhow!("debugger payload request timed out")),
486494
};
487495

488496
let status = response.status().as_u16();
@@ -673,7 +681,10 @@ pub fn generate_new_id() -> Uuid {
673681
#[cfg(test)]
674682
mod tests {
675683
use super::*;
684+
use libdd_capabilities::{ChunkFuture, HttpError, MaybeSend, StreamingBodySender};
676685
use std::borrow::Cow;
686+
use std::sync::atomic::{AtomicBool, Ordering};
687+
use std::sync::Arc;
677688

678689
fn agent_endpoint() -> Endpoint {
679690
Endpoint::from_slice("http://localhost:8126")
@@ -786,6 +797,107 @@ mod tests {
786797
}
787798
}
788799

800+
struct SetOnDrop(Arc<AtomicBool>);
801+
802+
impl Drop for SetOnDrop {
803+
fn drop(&mut self) {
804+
self.0.store(true, Ordering::SeqCst);
805+
}
806+
}
807+
808+
/// A response that never arrives, holding `guard` for as long as it lives.
809+
///
810+
/// The guard is captured by move rather than constructed in the body, because an
811+
/// async block does not run its body until first polled: a task aborted before
812+
/// its first poll would otherwise never arm the observer.
813+
async fn stalled_response(guard: SetOnDrop) -> Result<http::Response<Bytes>, HttpError> {
814+
let _guard = guard;
815+
future::pending::<()>().await;
816+
unreachable!()
817+
}
818+
819+
/// Accepts and discards body chunks, so the test's stalled response future is the
820+
/// only thing the spawned task ever waits on.
821+
struct DiscardingBodySender;
822+
823+
impl StreamingBodySender for DiscardingBodySender {
824+
fn send_chunk(&mut self, _data: Bytes) -> ChunkFuture<'_> {
825+
Box::pin(async { Ok(()) })
826+
}
827+
}
828+
829+
/// A client whose requests never complete, so a test can observe what happens to
830+
/// the in-flight task once the sender goes away. The flag is set when the
831+
/// response future is dropped, which happens only if the spawned task was
832+
/// aborted rather than detached.
833+
///
834+
/// `request_streamed` is overridden rather than relying on the default
835+
/// implementation: that one parks on the body channel until the `BodySender` is
836+
/// dropped, so a task abandoned before `finish()` would be cancelled before it
837+
/// ever reached `request()` and the flag would never be armed.
838+
#[derive(Clone, Debug)]
839+
struct StalledClient(Arc<AtomicBool>);
840+
841+
impl HttpClientCapability for StalledClient {
842+
fn new_client() -> Self {
843+
Self(Arc::new(AtomicBool::new(false)))
844+
}
845+
846+
fn new_without_connection_pooling() -> Self {
847+
Self::new_client()
848+
}
849+
850+
fn request(
851+
&self,
852+
_req: http::Request<Bytes>,
853+
) -> impl std::future::Future<Output = Result<http::Response<Bytes>, HttpError>> + MaybeSend
854+
{
855+
stalled_response(SetOnDrop(self.0.clone()))
856+
}
857+
858+
fn request_streamed(&self, _req: http::Request<()>) -> (BodySender, ResponseFuture) {
859+
(
860+
Box::new(DiscardingBodySender),
861+
Box::pin(stalled_response(SetOnDrop(self.0.clone()))),
862+
)
863+
}
864+
}
865+
866+
#[tokio::test]
867+
async fn test_dropping_payload_sender_aborts_in_flight_request() {
868+
let cancelled = Arc::new(AtomicBool::new(false));
869+
870+
let mut config = Config::default();
871+
config.set_endpoint(agent_endpoint()).unwrap();
872+
873+
let mut sender = PayloadSender::new_to_endpoint_with_client(
874+
config.snapshots_endpoint.as_ref().unwrap(),
875+
DebuggerType::Snapshots,
876+
"",
877+
StalledClient(cancelled.clone()),
878+
)
879+
.unwrap();
880+
881+
// Spawns the request task, which then never completes.
882+
sender.append(b"[{}]").await.unwrap();
883+
884+
// Abandoning the sender (an enclosing timeout, a `select!`, a cancelled task)
885+
// must cancel that request instead of leaving it running with its connection.
886+
drop(sender);
887+
888+
for _ in 0..100 {
889+
if cancelled.load(Ordering::SeqCst) {
890+
break;
891+
}
892+
tokio::task::yield_now().await;
893+
}
894+
895+
assert!(
896+
cancelled.load(Ordering::SeqCst),
897+
"in-flight request was detached instead of aborted"
898+
);
899+
}
900+
789901
#[test]
790902
fn test_payload_rejected_display_is_downcastable() {
791903
let err: anyhow::Error = anyhow::Error::from(PayloadRejected {

datadog-sidecar-ffi/src/lib.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -456,7 +456,7 @@ pub unsafe extern "C" fn ddog_sidecar_telemetry_enqueueConfig(
456456
let seq_id = seq_id.to_std();
457457
let config_entry = TelemetryActions::AddConfig(data::Configuration {
458458
name: config_key.to_utf8_lossy().into_owned(),
459-
value: config_value.to_utf8_lossy().into_owned(),
459+
value: Some(config_value.to_utf8_lossy().into_owned()),
460460
origin,
461461
config_id,
462462
seq_id,
@@ -487,6 +487,9 @@ pub unsafe extern "C" fn ddog_sidecar_telemetry_addEndpoint(
487487
path: Some(path.to_utf8_lossy().into_owned()),
488488
operation_name: operation_name.to_utf8_lossy().into_owned(),
489489
resource_name: resource_name.to_utf8_lossy().into_owned(),
490+
request_body_type: None,
491+
response_body_type: None,
492+
response_code: None,
490493
});
491494

492495
try_c!(blocking::enqueue_actions(
@@ -515,6 +518,8 @@ pub unsafe extern "C" fn ddog_sidecar_telemetry_addDependency(
515518
let dependency = TelemetryActions::AddDependency(Dependency {
516519
name: dependency_name.to_utf8_lossy().into_owned(),
517520
version,
521+
hash: None,
522+
metadata: None,
518523
});
519524

520525
try_c!(blocking::enqueue_actions(
@@ -547,6 +552,7 @@ pub unsafe extern "C" fn ddog_sidecar_telemetry_addIntegration(
547552
version,
548553
compatible: None,
549554
auto_enabled: None,
555+
error: None,
550556
});
551557

552558
try_c!(blocking::enqueue_actions(

libdd-capabilities-impl/src/http.rs

Lines changed: 39 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -59,23 +59,51 @@ mod native {
5959
}
6060
}
6161

62-
/// Write `body` as a newline-terminated record to the file referenced by `uri` (which must
63-
/// have a `file://` scheme), then return a synthetic 202 response.
62+
/// Record `body` to the on-disk location referenced by `uri` (which must have a `file://`
63+
/// scheme), then return a synthetic 202 response.
64+
///
65+
/// If the location is a directory (the path ends with a separator, as the offline
66+
/// telemetry writer passes `file:///dir/`), each request is written to its own
67+
/// `telemetry-<seq>-<pid>-<ts>.json` file inside it — ordinal-first so a lexicographic sort
68+
/// reproduces emission order. Otherwise the body is appended as a newline-terminated record to
69+
/// that single file.
6470
fn write_to_file_endpoint(
6571
uri: &http::Uri,
6672
body: bytes::Bytes,
6773
) -> Result<http::Response<bytes::Bytes>, HttpError> {
6874
let path = libdd_common::decode_uri_path_in_authority(uri)
6975
.map_err(|e| HttpError::Other(anyhow::anyhow!("invalid file:// URI: {e}")))?;
70-
let mut file = OpenOptions::new()
71-
.create(true)
72-
.append(true)
73-
.open(&path)
74-
.map_err(|e| HttpError::Other(anyhow::anyhow!("opening {path:?}: {e}")))?;
75-
let mut record = body.to_vec();
76-
record.push(b'\n');
77-
file.write_all(&record)
78-
.map_err(|e| HttpError::Other(anyhow::anyhow!("writing {path:?}: {e}")))?;
76+
77+
let is_dir = path.to_string_lossy().ends_with(std::path::MAIN_SEPARATOR) || path.is_dir();
78+
if is_dir {
79+
std::fs::create_dir_all(&path)
80+
.map_err(|e| HttpError::Other(anyhow::anyhow!("creating {path:?}: {e}")))?;
81+
// Process-wide sequence so successive requests get distinct, ordered filenames.
82+
static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
83+
let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
84+
let ts = std::time::SystemTime::now()
85+
.duration_since(std::time::UNIX_EPOCH)
86+
.map(|d| d.as_nanos())
87+
.unwrap_or(0);
88+
let name = format!("telemetry-{seq:020}-{}-{ts}.json", std::process::id());
89+
// Write to a temp file then rename, so a concurrent reader never sees a partial file.
90+
let dest = path.join(name);
91+
let tmp = dest.with_extension("json.tmp");
92+
std::fs::write(&tmp, &body)
93+
.map_err(|e| HttpError::Other(anyhow::anyhow!("writing {tmp:?}: {e}")))?;
94+
std::fs::rename(&tmp, &dest)
95+
.map_err(|e| HttpError::Other(anyhow::anyhow!("renaming to {dest:?}: {e}")))?;
96+
} else {
97+
let mut file = OpenOptions::new()
98+
.create(true)
99+
.append(true)
100+
.open(&path)
101+
.map_err(|e| HttpError::Other(anyhow::anyhow!("opening {path:?}: {e}")))?;
102+
let mut record = body.to_vec();
103+
record.push(b'\n');
104+
file.write_all(&record)
105+
.map_err(|e| HttpError::Other(anyhow::anyhow!("writing {path:?}: {e}")))?;
106+
}
79107

80108
http::Response::builder()
81109
.status(http::StatusCode::ACCEPTED)

libdd-common/src/dump_server.rs

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,13 @@
99
//! This is primarily used for testing to validate the exact bytes sent over the wire.
1010
1111
use anyhow::Context;
12+
use core::sync::atomic::{AtomicU64, Ordering};
1213
use std::path::PathBuf;
13-
use tokio::io::AsyncReadExt;
14+
use tokio::io::{AsyncReadExt, AsyncWriteExt};
15+
16+
/// Monotonic counter for directory-mode dump filenames, so each captured request lands in
17+
/// its own lexicographically-ordered file instead of overwriting a single one.
18+
static DUMP_SEQ: AtomicU64 = AtomicU64::new(0);
1419

1520
/// Helper to find subsequence in bytes
1621
fn find_subsequence(haystack: &[u8], needle: &[u8]) -> Option<usize> {
@@ -385,13 +390,55 @@ async fn write_request_to_file(
385390
return Ok(());
386391
}
387392

393+
// A path ending in a separator (e.g. `file:///dir/`) selects "directory mode": write each
394+
// request's body as its own lexicographically-ordered file so requests accumulate instead of
395+
// overwriting a single dump file.
396+
let path_str = output_path.as_os_str().to_string_lossy();
397+
if path_str.ends_with('/') || path_str.ends_with(std::path::MAIN_SEPARATOR) {
398+
let body = if parsed_request.is_chunked {
399+
decode_chunked_body(&parsed_request.raw_data[parsed_request.headers_len..])?
400+
} else {
401+
parsed_request.raw_data[parsed_request.headers_len..].to_vec()
402+
};
403+
if body.is_empty() {
404+
return Ok(());
405+
}
406+
tokio::fs::create_dir_all(output_path)
407+
.await
408+
.context("Failed to create dump directory")?;
409+
// Ordinal-first name so files sort in emission order; the PID avoids cross-process
410+
// collisions when several processes share the directory.
411+
let name = format!(
412+
"{:020}-{}.json",
413+
DUMP_SEQ.fetch_add(1, Ordering::Relaxed),
414+
std::process::id()
415+
);
416+
let dest = output_path.join(&name);
417+
let tmp = output_path.join(format!("{name}.tmp"));
418+
{
419+
let mut file = tokio::fs::File::create(&tmp)
420+
.await
421+
.context("Failed to create dump file")?;
422+
file.write_all(&body)
423+
.await
424+
.context("Failed to write request dump")?;
425+
file.sync_all()
426+
.await
427+
.context("Failed to sync request dump to disk")?;
428+
}
429+
// Atomic publish via rename so readers never observe a partial file.
430+
tokio::fs::rename(&tmp, &dest)
431+
.await
432+
.context("Failed to publish request dump")?;
433+
return Ok(());
434+
}
435+
388436
let data_to_write = if parsed_request.is_chunked && parsed_request.headers_len > 0 {
389437
reconstruct_with_content_length(&parsed_request.raw_data, parsed_request.headers_len)?
390438
} else {
391439
parsed_request.raw_data.clone()
392440
};
393441

394-
use tokio::io::AsyncWriteExt;
395442
let mut file = tokio::fs::File::create(output_path)
396443
.await
397444
.context("Failed to create dump file")?;

0 commit comments

Comments
 (0)