Skip to content

Commit 5b5a120

Browse files
Report profiling data in v2.4 intake format; compress files (#53)
* Report profiling data in v2.4 intake format This was tested with both the Ruby (agent and agentless modes) and the PHP profilers. This also introduces a breaking API change: the `ddog_ProfileExporter_build` / `ProfileExporter::build` functions now take two additional arguments -- the profiling library name and version. Other than that change, using the v2.4 intake format is transparent to the libdatadog users. Thanks to @morrisonlevi for pairing with me on this. Note that this does not (yet) include support for including attributes in the reporting data. I'll leave that for a separate PR. * Adjust profiler_tags encoding * Remove data[] from the name of the files * Add lz4 compression to files * Don't compress event.json * Rename profile_library_[name|version] to profiling_library_[name|version] * Test for DD-EVP-ORIGIN* * Move profiling_library_{name,version} to constructor * Document some intake details Co-authored-by: Levi Morrison <[email protected]>
1 parent 78969e5 commit 5b5a120

File tree

8 files changed

+200
-34
lines changed

8 files changed

+200
-34
lines changed

Cargo.lock

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

LICENSE-3rdparty.yml

Lines changed: 20 additions & 0 deletions
Large diffs are not rendered by default.

examples/ffi/exporter.cpp

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -90,8 +90,13 @@ int main(int argc, char *argv[]) {
9090

9191
ddog_PushTagResult_drop(tag_result);
9292

93-
ddog_NewProfileExporterResult exporter_new_result =
94-
ddog_ProfileExporter_new(DDOG_CHARSLICE_C("native"), &tags, endpoint);
93+
ddog_NewProfileExporterResult exporter_new_result = ddog_ProfileExporter_new(
94+
DDOG_CHARSLICE_C("exporter-example"),
95+
DDOG_CHARSLICE_C("1.2.3"),
96+
DDOG_CHARSLICE_C("native"),
97+
&tags,
98+
endpoint
99+
);
95100
ddog_Vec_tag_drop(tags);
96101

97102
if (exporter_new_result.tag == DDOG_NEW_PROFILE_EXPORTER_RESULT_ERR) {
@@ -109,8 +114,14 @@ int main(int argc, char *argv[]) {
109114

110115
ddog_Slice_file files = {.ptr = files_, .len = sizeof files_ / sizeof *files_};
111116

112-
ddog_Request *request = ddog_ProfileExporter_build(exporter, encoded_profile->start,
113-
encoded_profile->end, files, nullptr, 30000);
117+
ddog_Request *request = ddog_ProfileExporter_build(
118+
exporter,
119+
encoded_profile->start,
120+
encoded_profile->end,
121+
files,
122+
nullptr,
123+
30000
124+
);
114125

115126
ddog_CancellationToken *cancel = ddog_CancellationToken_new();
116127
ddog_CancellationToken *cancel_for_background_thread = ddog_CancellationToken_clone(cancel);

profiling-ffi/src/exporter.rs

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -116,15 +116,25 @@ unsafe fn try_to_endpoint(endpoint: Endpoint) -> anyhow::Result<exporter::Endpoi
116116
#[must_use]
117117
#[export_name = "ddog_ProfileExporter_new"]
118118
pub extern "C" fn profile_exporter_new(
119+
profiling_library_name: CharSlice,
120+
profiling_library_version: CharSlice,
119121
family: CharSlice,
120122
tags: Option<&ddcommon_ffi::Vec<Tag>>,
121123
endpoint: Endpoint,
122124
) -> NewProfileExporterResult {
123125
match || -> anyhow::Result<ProfileExporter> {
126+
let library_name = unsafe { profiling_library_name.to_utf8_lossy() }.into_owned();
127+
let library_version = unsafe { profiling_library_version.to_utf8_lossy() }.into_owned();
124128
let family = unsafe { family.to_utf8_lossy() }.into_owned();
125129
let converted_endpoint = unsafe { try_to_endpoint(endpoint)? };
126130
let tags = tags.map(|tags| tags.iter().map(Tag::clone).collect());
127-
ProfileExporter::new(family, tags, converted_endpoint)
131+
ProfileExporter::new(
132+
library_name,
133+
library_version,
134+
family,
135+
tags,
136+
converted_endpoint,
137+
)
128138
}() {
129139
Ok(exporter) => NewProfileExporterResult::Ok(Box::into_raw(Box::new(exporter))),
130140
Err(err) => NewProfileExporterResult::Err(err.into()),
@@ -316,6 +326,14 @@ mod test {
316326
use crate::exporter::*;
317327
use ddcommon_ffi::Slice;
318328

329+
fn profiling_library_name() -> CharSlice<'static> {
330+
CharSlice::from("dd-trace-foo")
331+
}
332+
333+
fn profiling_library_version() -> CharSlice<'static> {
334+
CharSlice::from("1.2.3")
335+
}
336+
319337
fn family() -> CharSlice<'static> {
320338
CharSlice::from("native")
321339
}
@@ -334,22 +352,34 @@ mod test {
334352
let host = Tag::new("host", "localhost").expect("static tags to be valid");
335353
tags.push(host);
336354

337-
let result = profile_exporter_new(family(), Some(&tags), endpoint_agent(endpoint()));
355+
let result = profile_exporter_new(
356+
profiling_library_name(),
357+
profiling_library_version(),
358+
family(),
359+
Some(&tags),
360+
endpoint_agent(endpoint()),
361+
);
338362

339363
match result {
340364
NewProfileExporterResult::Ok(exporter) => unsafe {
341365
profile_exporter_delete(Some(Box::from_raw(exporter)))
342366
},
343367
NewProfileExporterResult::Err(message) => {
344-
std::mem::drop(message);
368+
drop(message);
345369
panic!("Should not occur!")
346370
}
347371
}
348372
}
349373

350374
#[test]
351375
fn test_build() {
352-
let exporter_result = profile_exporter_new(family(), None, endpoint_agent(endpoint()));
376+
let exporter_result = profile_exporter_new(
377+
profiling_library_name(),
378+
profiling_library_version(),
379+
family(),
380+
None,
381+
endpoint_agent(endpoint()),
382+
);
353383

354384
let exporter = match exporter_result {
355385
NewProfileExporterResult::Ok(exporter) => unsafe {

profiling/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,11 @@ hyper = {version = "0.14", features = ["client"], default-features = false}
2828
hyper-multipart-rfc7578 = "0.7.0"
2929
indexmap = "1.8"
3030
libc = "0.2"
31+
lz4_flex = { version = "0.9", default-features = false, features = ["std", "safe-encode", "frame"] }
32+
mime = "0.3.16"
3133
mime_guess = {version = "2.0", default-features = false}
3234
percent-encoding = "2.1"
3335
prost = "0.10"
36+
serde_json = {version = "1.0"}
3437
tokio = {version = "1.8", features = ["rt", "macros"]}
3538
tokio-util = "0.7.1"

profiling/src/exporter/config.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ pub fn agentless<AsStrRef: AsRef<str>, IntoCow: Into<Cow<'static, str>>>(
6161
site: AsStrRef,
6262
api_key: IntoCow,
6363
) -> anyhow::Result<Endpoint> {
64-
let intake_url: String = format!("https://intake.profile.{}/v1/input", site.as_ref());
64+
let intake_url: String = format!("https://intake.profile.{}/api/v2/profile", site.as_ref());
6565

6666
Ok(Endpoint {
6767
url: Uri::from_str(intake_url.as_str())?,

profiling/src/exporter/mod.rs

Lines changed: 61 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ pub use chrono::{DateTime, Utc};
1010
pub use ddcommon::tag::Tag;
1111
pub use hyper::Uri;
1212
use hyper_multipart_rfc7578::client::multipart;
13+
use lz4_flex::frame::FrameEncoder;
14+
use mime;
15+
use serde_json::json;
16+
use std::io::Write;
1317
use tokio::runtime::Runtime;
1418
use tokio_util::sync::CancellationToken;
1519

@@ -40,6 +44,8 @@ pub struct ProfileExporter {
4044
exporter: Exporter,
4145
endpoint: Endpoint,
4246
family: Cow<'static, str>,
47+
profiling_library_name: Cow<'static, str>,
48+
profiling_library_version: Cow<'static, str>,
4349
tags: Option<Vec<Tag>>,
4450
}
4551

@@ -106,15 +112,24 @@ impl Request {
106112
}
107113

108114
impl ProfileExporter {
109-
pub fn new<IntoCow: Into<Cow<'static, str>>>(
110-
family: IntoCow,
115+
pub fn new<F, N, V>(
116+
profiling_library_name: N,
117+
profiling_library_version: V,
118+
family: F,
111119
tags: Option<Vec<Tag>>,
112120
endpoint: Endpoint,
113-
) -> anyhow::Result<ProfileExporter> {
121+
) -> anyhow::Result<ProfileExporter>
122+
where
123+
F: Into<Cow<'static, str>>,
124+
N: Into<Cow<'static, str>>,
125+
V: Into<Cow<'static, str>>,
126+
{
114127
Ok(Self {
115128
exporter: Exporter::new()?,
116129
endpoint,
117130
family: family.into(),
131+
profiling_library_name: profiling_library_name.into(),
132+
profiling_library_version: profiling_library_version.into(),
118133
tags,
119134
})
120135
}
@@ -130,30 +145,58 @@ impl ProfileExporter {
130145
) -> anyhow::Result<Request> {
131146
let mut form = multipart::Form::default();
132147

133-
form.add_text("version", "3");
134-
form.add_text("start", start.format("%Y-%m-%dT%H:%M:%S%.9fZ").to_string());
135-
form.add_text("end", end.format("%Y-%m-%dT%H:%M:%S%.9fZ").to_string());
136-
form.add_text("family", self.family.as_ref());
137-
138-
for tags in self.tags.as_ref().iter().chain(additional_tags.iter()) {
139-
for tag in tags.iter() {
140-
form.add_text("tags[]", tag.to_string());
141-
}
148+
// combine tags and additional_tags
149+
let mut tags_profiler = String::new();
150+
let other_tags = additional_tags.into_iter();
151+
for tag in self.tags.iter().chain(other_tags).flatten() {
152+
tags_profiler.push_str(tag.as_ref());
153+
tags_profiler.push(',');
142154
}
155+
tags_profiler.pop(); // clean up the trailing comma
156+
157+
let attachments: Vec<String> = files.iter().map(|file| file.name.to_owned()).collect();
158+
159+
let event = json!({
160+
"attachments": attachments,
161+
"tags_profiler": tags_profiler,
162+
"start": start.format("%Y-%m-%dT%H:%M:%S%.9fZ").to_string(),
163+
"end": end.format("%Y-%m-%dT%H:%M:%S%.9fZ").to_string(),
164+
"family": self.family.as_ref(),
165+
"version": "4",
166+
})
167+
.to_string();
168+
169+
form.add_reader_file_with_mime(
170+
// Intake does not look for filename=event.json, it looks for name=event.
171+
"event",
172+
// this one shouldn't be compressed
173+
Cursor::new(event),
174+
"event.json",
175+
mime::APPLICATION_JSON,
176+
);
143177

144178
for file in files {
145-
form.add_reader_file(
146-
format!("data[{}]", file.name),
147-
Cursor::new(file.bytes.to_owned()),
148-
file.name,
149-
)
179+
let mut encoder = FrameEncoder::new(Vec::new());
180+
encoder.write_all(file.bytes)?;
181+
let encoded = encoder.finish()?;
182+
/* The Datadog RFC examples strip off the file extension, but the exact behavior isn't
183+
* specified. This does the simple thing of using the filename without modification for
184+
* the form name because intake does not care about these name of the form field for
185+
* these attachments.
186+
*/
187+
form.add_reader_file(file.name, Cursor::new(encoded), file.name)
150188
}
151189

152190
let builder = self
153191
.endpoint
154192
.into_request_builder(concat!("DDProf/", env!("CARGO_PKG_VERSION")))?
155193
.method(http::Method::POST)
156-
.header("Connection", "close");
194+
.header("Connection", "close")
195+
.header("DD-EVP-ORIGIN", self.profiling_library_name.as_ref())
196+
.header(
197+
"DD-EVP-ORIGIN-VERSION",
198+
self.profiling_library_version.as_ref(),
199+
);
157200

158201
Ok(
159202
Request::from(form.set_body_convert::<hyper::Body, multipart::Body>(builder)?)

profiling/tests/form.rs

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -55,10 +55,18 @@ mod tests {
5555

5656
#[test]
5757
fn multipart_agent() {
58+
let profiling_library_name = "dd-trace-foo";
59+
let profiling_library_version = "1.2.3";
5860
let base_url = "http://localhost:8126".parse().expect("url to parse");
5961
let endpoint = config::agent(base_url).expect("endpoint to construct");
60-
let exporter = ProfileExporter::new("php", Some(default_tags()), endpoint)
61-
.expect("exporter to construct");
62+
let exporter = ProfileExporter::new(
63+
profiling_library_name,
64+
profiling_library_version,
65+
"php",
66+
Some(default_tags()),
67+
endpoint,
68+
)
69+
.expect("exporter to construct");
6270

6371
let request = multipart(&exporter);
6472

@@ -69,27 +77,50 @@ mod tests {
6977

7078
let actual_headers = request.headers();
7179
assert!(!actual_headers.contains_key("DD-API-KEY"));
80+
assert_eq!(
81+
actual_headers.get("DD-EVP-ORIGIN").unwrap(),
82+
profiling_library_name
83+
);
84+
assert_eq!(
85+
actual_headers.get("DD-EVP-ORIGIN-VERSION").unwrap(),
86+
profiling_library_version
87+
);
7288
}
7389

7490
#[test]
7591
fn multipart_agentless() {
92+
let profiling_library_name = "dd-trace-foo";
93+
let profiling_library_version = "1.2.3";
7694
let api_key = "1234567890123456789012";
7795
let endpoint = config::agentless("datadoghq.com", api_key).expect("endpoint to construct");
78-
let exporter = ProfileExporter::new("php", Some(default_tags()), endpoint)
79-
.expect("exporter to construct");
96+
let exporter = ProfileExporter::new(
97+
profiling_library_name,
98+
profiling_library_version,
99+
"php",
100+
Some(default_tags()),
101+
endpoint,
102+
)
103+
.expect("exporter to construct");
80104

81105
let request = multipart(&exporter);
82106

83107
assert_eq!(
84108
request.uri().to_string(),
85-
"https://intake.profile.datadoghq.com/v1/input"
109+
"https://intake.profile.datadoghq.com/api/v2/profile"
86110
);
87111

88112
let actual_headers = request.headers();
89113

114+
assert_eq!(actual_headers.get("DD-API-KEY").unwrap(), api_key);
115+
116+
assert_eq!(
117+
actual_headers.get("DD-EVP-ORIGIN").unwrap(),
118+
profiling_library_name
119+
);
120+
90121
assert_eq!(
91-
actual_headers.get("DD-API-KEY").expect("api key to exist"),
92-
api_key
122+
actual_headers.get("DD-EVP-ORIGIN-VERSION").unwrap(),
123+
profiling_library_version
93124
);
94125
}
95126
}

0 commit comments

Comments
 (0)