Skip to main content

httpgenerator_cli/
telemetry.rs

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}