1use crate::args::{CliArgs, OutputTypeArg};
2use httpgenerator_core::{
3 anonymous_identity, redact_authorization_headers, support_key_from_anonymous_identity,
4};
5use serde_json::{Map, Value};
6use std::ffi::OsString;
7
8const REDACTED: &str = "[REDACTED]";
9
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct TelemetryContext {
12 pub support_key: String,
13 pub anonymous_identity: String,
14 pub command_line: String,
15}
16
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct FeatureUsageEvent {
19 pub feature_name: String,
20 pub support_key: String,
21 pub anonymous_identity: String,
22}
23
24#[derive(Debug, Clone, PartialEq)]
25pub struct ErrorEvent {
26 pub error_type: String,
27 pub message: String,
28 pub support_key: String,
29 pub anonymous_identity: String,
30 pub command_line: String,
31 pub settings_json: String,
32 pub settings: Map<String, Value>,
33}
34
35#[derive(Debug, Clone, PartialEq)]
36pub enum TelemetryEvent {
37 FeatureUsage(FeatureUsageEvent),
38 Error(ErrorEvent),
39}
40
41pub trait TelemetrySink {
42 fn emit(&mut self, event: TelemetryEvent);
43}
44
45#[derive(Debug, Default)]
46pub struct NoopTelemetrySink;
47
48impl TelemetrySink for NoopTelemetrySink {
49 fn emit(&mut self, _event: TelemetryEvent) {}
50}
51
52#[derive(Debug, Default)]
53pub struct MemoryTelemetrySink {
54 events: Vec<TelemetryEvent>,
55}
56
57impl MemoryTelemetrySink {
58 pub fn events(&self) -> &[TelemetryEvent] {
59 &self.events
60 }
61}
62
63impl TelemetrySink for MemoryTelemetrySink {
64 fn emit(&mut self, event: TelemetryEvent) {
65 self.events.push(event);
66 }
67}
68
69pub struct TelemetryRecorder<S> {
70 context: Option<TelemetryContext>,
71 sink: S,
72}
73
74impl<S> TelemetryRecorder<S>
75where
76 S: TelemetrySink,
77{
78 pub fn from_cli_args(raw_args: &[OsString], args: &CliArgs, sink: S) -> Self {
79 let context = (!args.no_logging).then(|| {
80 let anonymous_identity = anonymous_identity();
81 let support_key = support_key_from_anonymous_identity(&anonymous_identity);
82
83 TelemetryContext {
84 support_key,
85 anonymous_identity,
86 command_line: redacted_command_line(raw_args),
87 }
88 });
89
90 Self { context, sink }
91 }
92
93 pub fn record_feature_usage(&mut self, args: &CliArgs) {
94 let Some(context) = &self.context else {
95 return;
96 };
97
98 for feature_name in feature_usage_names(args) {
99 self.sink
100 .emit(TelemetryEvent::FeatureUsage(FeatureUsageEvent {
101 feature_name,
102 support_key: context.support_key.clone(),
103 anonymous_identity: context.anonymous_identity.clone(),
104 }));
105 }
106 }
107
108 pub fn record_error(&mut self, args: &CliArgs, error_type: &str, message: &str) {
109 let Some(context) = &self.context else {
110 return;
111 };
112
113 let settings = redacted_settings(args);
114 let settings_json = Value::Object(settings.clone()).to_string();
115
116 self.sink.emit(TelemetryEvent::Error(ErrorEvent {
117 error_type: error_type.to_string(),
118 message: message.to_string(),
119 support_key: context.support_key.clone(),
120 anonymous_identity: context.anonymous_identity.clone(),
121 command_line: context.command_line.clone(),
122 settings_json,
123 settings,
124 }));
125 }
126
127 pub fn into_sink(self) -> S {
128 self.sink
129 }
130}
131
132fn feature_usage_names(args: &CliArgs) -> Vec<String> {
133 let mut features = Vec::new();
134
135 if args.skip_validation {
136 features.push("skip-validation".to_string());
137 }
138
139 if args.authorization_header.is_some() {
140 features.push("authorization-header".to_string());
141 }
142
143 if args.authorization_header_from_environment_variable {
144 features.push("load-authorization-header-from-environment".to_string());
145 }
146
147 features.push("authorization-header-variable-name".to_string());
148 features.push("content-type".to_string());
149
150 if args.base_url.is_some() {
151 features.push("base-url".to_string());
152 }
153
154 features.push("output-type".to_string());
155
156 if args.azure_scope.is_some() {
157 features.push("azure-scope".to_string());
158 }
159
160 if args.azure_tenant_id.is_some() {
161 features.push("azure-tenant-id".to_string());
162 }
163
164 features.push("timeout".to_string());
165
166 if args.generate_intellij_tests {
167 features.push("generate-intellij-tests".to_string());
168 }
169
170 if !args.custom_headers.is_empty() {
171 features.push("custom-header".to_string());
172 }
173
174 if args.skip_headers {
175 features.push("skip-headers".to_string());
176 }
177
178 features
179}
180
181fn redacted_command_line(raw_args: &[OsString]) -> String {
182 let mut arguments = raw_args
183 .iter()
184 .map(|value| value.to_string_lossy().into_owned())
185 .collect::<Vec<_>>();
186
187 if let Some(program_name) = arguments.first_mut() {
188 *program_name = "httpgenerator".to_string();
189 }
190
191 redact_authorization_headers(&arguments.join(" "))
192}
193
194fn redacted_settings(args: &CliArgs) -> Map<String, Value> {
195 let mut settings = Map::new();
196
197 settings.insert(
198 "openApiPath".to_string(),
199 option_string_value(args.open_api_path.as_deref()),
200 );
201 settings.insert(
202 "outputFolder".to_string(),
203 Value::String(args.output_folder.clone()),
204 );
205 settings.insert("noLogging".to_string(), Value::Bool(args.no_logging));
206 settings.insert(
207 "skipValidation".to_string(),
208 Value::Bool(args.skip_validation),
209 );
210 settings.insert(
211 "authorizationHeader".to_string(),
212 redacted_authorization_value(args.authorization_header.as_deref()),
213 );
214 settings.insert(
215 "authorizationHeaderFromEnvironmentVariable".to_string(),
216 Value::Bool(args.authorization_header_from_environment_variable),
217 );
218 settings.insert(
219 "authorizationHeaderVariableName".to_string(),
220 Value::String(args.authorization_header_variable_name.clone()),
221 );
222 settings.insert(
223 "contentType".to_string(),
224 Value::String(args.content_type.clone()),
225 );
226 settings.insert(
227 "baseUrl".to_string(),
228 option_string_value(args.base_url.as_deref()),
229 );
230 settings.insert(
231 "outputType".to_string(),
232 Value::from(output_type_ordinal(args.output_type)),
233 );
234 settings.insert(
235 "azureScope".to_string(),
236 option_string_value(args.azure_scope.as_deref()),
237 );
238 settings.insert(
239 "azureTenantId".to_string(),
240 option_string_value(args.azure_tenant_id.as_deref()),
241 );
242 settings.insert("timeout".to_string(), Value::from(args.timeout));
243 settings.insert(
244 "generateIntellijTests".to_string(),
245 Value::Bool(args.generate_intellij_tests),
246 );
247 settings.insert(
248 "customHeaders".to_string(),
249 Value::Array(
250 args.custom_headers
251 .iter()
252 .map(|value| Value::String(redact_custom_header(value)))
253 .collect(),
254 ),
255 );
256 settings.insert("skipHeaders".to_string(), Value::Bool(args.skip_headers));
257
258 settings
259}
260
261fn option_string_value(value: Option<&str>) -> Value {
262 value
263 .map(|value| Value::String(value.to_string()))
264 .unwrap_or(Value::Null)
265}
266
267fn redacted_authorization_value(value: Option<&str>) -> Value {
268 value
269 .map(|_| Value::String(REDACTED.to_string()))
270 .unwrap_or(Value::Null)
271}
272
273fn output_type_ordinal(output_type: OutputTypeArg) -> u8 {
274 match output_type {
275 OutputTypeArg::OneRequestPerFile => 0,
276 OutputTypeArg::OneFile => 1,
277 OutputTypeArg::OneFilePerTag => 2,
278 }
279}
280
281fn redact_custom_header(value: &str) -> String {
282 let Some((name, _)) = value.split_once(':') else {
283 return value.to_string();
284 };
285
286 if name.trim().eq_ignore_ascii_case("authorization") {
287 format!("{}: {REDACTED}", name.trim())
288 } else {
289 value.to_string()
290 }
291}
292
293#[cfg(test)]
294mod tests {
295 use super::{
296 MemoryTelemetrySink, TelemetryEvent, TelemetryRecorder, feature_usage_names,
297 redacted_command_line,
298 };
299 use crate::args::{CliArgs, OutputTypeArg};
300 use std::ffi::OsString;
301
302 #[test]
303 fn feature_usage_matches_dotnet_rules_for_default_args() {
304 assert_eq!(
305 feature_usage_names(&CliArgs::default()),
306 vec![
307 "authorization-header-variable-name",
308 "content-type",
309 "output-type",
310 "timeout",
311 ]
312 .into_iter()
313 .map(str::to_string)
314 .collect::<Vec<_>>()
315 );
316 }
317
318 #[test]
319 fn feature_usage_tracks_enabled_and_configured_options() {
320 let args = CliArgs {
321 skip_validation: true,
322 authorization_header: Some("Bearer secret-token".to_string()),
323 authorization_header_from_environment_variable: true,
324 base_url: Some("https://api.example.com".to_string()),
325 output_type: OutputTypeArg::OneFilePerTag,
326 azure_scope: Some("api://example/.default".to_string()),
327 azure_tenant_id: Some("tenant-id".to_string()),
328 generate_intellij_tests: true,
329 custom_headers: vec!["X-Test: 1".to_string()],
330 skip_headers: true,
331 ..CliArgs::default()
332 };
333
334 assert_eq!(
335 feature_usage_names(&args),
336 vec![
337 "skip-validation",
338 "authorization-header",
339 "load-authorization-header-from-environment",
340 "authorization-header-variable-name",
341 "content-type",
342 "base-url",
343 "output-type",
344 "azure-scope",
345 "azure-tenant-id",
346 "timeout",
347 "generate-intellij-tests",
348 "custom-header",
349 "skip-headers",
350 ]
351 .into_iter()
352 .map(str::to_string)
353 .collect::<Vec<_>>()
354 );
355 }
356
357 #[test]
358 fn redacted_command_line_hides_authorization_headers_and_normalizes_program_name() {
359 let command_line = redacted_command_line(&[
360 OsString::from(r"C:\tools\httpgenerator.exe"),
361 OsString::from("petstore.json"),
362 OsString::from("--authorization-header"),
363 OsString::from("Bearer secret-token"),
364 OsString::from("--output"),
365 OsString::from("."),
366 ]);
367
368 assert_eq!(
369 command_line,
370 "httpgenerator petstore.json --authorization-header [REDACTED] --output ."
371 );
372 assert!(!command_line.contains("secret-token"));
373 }
374
375 #[test]
376 fn record_error_captures_redacted_settings_and_support_context() {
377 let args = CliArgs {
378 open_api_path: Some("petstore.json".to_string()),
379 authorization_header: Some("Bearer secret-token".to_string()),
380 custom_headers: vec![
381 "Authorization: Basic secret-value".to_string(),
382 "X-Test: 1".to_string(),
383 ],
384 ..CliArgs::default()
385 };
386 let raw_args = [
387 OsString::from(r"C:\tools\httpgenerator.exe"),
388 OsString::from("petstore.json"),
389 OsString::from("--authorization-header"),
390 OsString::from("Bearer secret-token"),
391 ];
392 let mut recorder =
393 TelemetryRecorder::from_cli_args(&raw_args, &args, MemoryTelemetrySink::default());
394
395 recorder.record_error(&args, "CliError", "boom");
396
397 let sink = recorder.into_sink();
398 assert_eq!(sink.events().len(), 1);
399
400 let TelemetryEvent::Error(event) = &sink.events()[0] else {
401 panic!("expected an error event");
402 };
403
404 assert_eq!(event.error_type, "CliError");
405 assert_eq!(event.message, "boom");
406 assert_eq!(event.support_key.len(), 7);
407 assert_eq!(event.anonymous_identity.len(), 44);
408 assert_eq!(
409 event.command_line,
410 "httpgenerator petstore.json --authorization-header [REDACTED]"
411 );
412 assert!(
413 event
414 .settings_json
415 .contains(r#""authorizationHeader":"[REDACTED]""#)
416 );
417 assert!(
418 event
419 .settings_json
420 .contains(r#""customHeaders":["Authorization: [REDACTED]","X-Test: 1"]"#)
421 );
422 assert!(!event.settings_json.contains("secret-token"));
423 assert!(!event.command_line.contains("secret-token"));
424 }
425
426 #[test]
427 fn no_logging_disables_feature_and_error_events() {
428 let args = CliArgs {
429 no_logging: true,
430 skip_headers: true,
431 ..CliArgs::default()
432 };
433 let mut recorder = TelemetryRecorder::from_cli_args(
434 &[OsString::from("httpgenerator")],
435 &args,
436 MemoryTelemetrySink::default(),
437 );
438
439 recorder.record_feature_usage(&args);
440 recorder.record_error(&args, "CliError", "boom");
441
442 assert!(recorder.into_sink().events().is_empty());
443 }
444
445 #[test]
446 fn record_feature_usage_emits_ordered_feature_events() {
447 let args = CliArgs {
448 skip_validation: true,
449 custom_headers: vec!["X-Test: 1".to_string()],
450 ..CliArgs::default()
451 };
452 let mut recorder = TelemetryRecorder::from_cli_args(
453 &[OsString::from("httpgenerator")],
454 &args,
455 MemoryTelemetrySink::default(),
456 );
457
458 recorder.record_feature_usage(&args);
459
460 let sink = recorder.into_sink();
461 let names = sink
462 .events()
463 .iter()
464 .map(|event| match event {
465 TelemetryEvent::FeatureUsage(event) => event.feature_name.as_str(),
466 TelemetryEvent::Error(_) => panic!("expected feature events"),
467 })
468 .collect::<Vec<_>>();
469
470 assert_eq!(
471 names,
472 vec![
473 "skip-validation",
474 "authorization-header-variable-name",
475 "content-type",
476 "output-type",
477 "timeout",
478 "custom-header",
479 ]
480 );
481 }
482}