Skip to main content

forge_ops_tracker/
change_tracking.rs

1// "What changed": the request bodies for an explicit `record_change` and for the snapshot sent
2// once at startup. Both are delivered by DeliveryQueue's worker thread like any event; this module
3// only builds them. The SDK stays stateless: the server diffs each snapshot against the last one
4// for the same project and environment and records what changed.
5//
6// The snapshot sends only what this process can know for certain. `runtime` is the compiler that
7// built the binary (see build.rs). Dependencies are left out entirely: a compiled Rust binary
8// carries no record of the crates linked into it that could be read back at runtime, and the
9// server treats a missing key as unknown rather than "everything was removed". Environment
10// variable names are opt-in, and values are never read at all.
11
12use std::collections::HashMap;
13use std::time::{SystemTime, UNIX_EPOCH};
14
15use crate::configuration::Configuration;
16use crate::event_builder::format_unix_timestamp;
17use crate::pii_scrubber::{json_string, Value};
18
19// The kinds the changes endpoint accepts; it rejects anything else, so an unknown kind is sent as
20// "other" instead.
21const KINDS: [&str; 6] = [
22    "feature_flag",
23    "config",
24    "migration",
25    "dependency",
26    "infrastructure",
27    "other",
28];
29
30const MAX_TITLE_CHARS: usize = 200;
31
32/// One change to record with [`record_change`](crate::record_change): a flag flip, a config edit, a
33/// migration, a deploy of infrastructure. Build one with [`Change::new`] and set whichever optional
34/// fields apply with struct update syntax:
35///
36/// ```no_run
37/// use forge_ops_tracker::{context, Change};
38///
39/// forge_ops_tracker::record_change(Change {
40///     details: context! {"flag" => "new_checkout", "enabled" => true},
41///     actor: Some("alice@example.com".to_string()),
42///     ..Change::new("feature_flag", "Enabled new checkout for everyone")
43/// });
44/// ```
45#[derive(Clone, Debug, PartialEq)]
46pub struct Change {
47    /// One of `"feature_flag"`, `"config"`, `"migration"`, `"dependency"`, `"infrastructure"`,
48    /// `"other"`; anything else is sent as `"other"`.
49    pub kind: String,
50    /// Required; truncated to 200 characters. A blank title is dropped, since it can't be stored.
51    pub title: String,
52    pub details: HashMap<String, Value>,
53    /// Defaults to `Configuration.environment`.
54    pub environment: Option<String>,
55    pub service: Option<String>,
56    pub actor: Option<String>,
57    /// A link to the change itself: a pull request, a flag's settings page. `http` or `https`.
58    pub url: Option<String>,
59    /// An idempotency key: recording the same id twice stores one change.
60    pub id: Option<String>,
61    /// Defaults to now.
62    pub occurred_at: Option<SystemTime>,
63}
64
65impl Change {
66    pub fn new(kind: &str, title: &str) -> Self {
67        Change {
68            kind: kind.to_string(),
69            title: title.to_string(),
70            details: HashMap::new(),
71            environment: None,
72            service: None,
73            actor: None,
74            url: None,
75            id: None,
76            occurred_at: None,
77        }
78    }
79}
80
81/// The `/changes` request body, or `None` for a blank title.
82pub fn change_body(configuration: &Configuration, change: &Change) -> Option<String> {
83    let title = change.title.trim();
84    if title.is_empty() {
85        return None;
86    }
87    let title: String = title.chars().take(MAX_TITLE_CHARS).collect();
88    let kind = if KINDS.contains(&change.kind.as_str()) {
89        change.kind.as_str()
90    } else {
91        "other"
92    };
93    let environment = change
94        .environment
95        .as_deref()
96        .unwrap_or(&configuration.environment);
97    let occurred_at = change
98        .occurred_at
99        .unwrap_or_else(SystemTime::now)
100        .duration_since(UNIX_EPOCH)
101        .map(|d| d.as_secs())
102        .unwrap_or(0);
103
104    let mut body = format!(
105        "{{\"kind\":{},\"title\":{},\"environment\":{},\"occurred_at\":{}",
106        json_string(kind),
107        json_string(&title),
108        json_string(environment),
109        json_string(&format_unix_timestamp(occurred_at)),
110    );
111    if !change.details.is_empty() {
112        body.push_str(",\"details\":");
113        body.push_str(&Value::Object(change.details.clone()).to_json());
114    }
115    for (key, value) in [
116        ("service", &change.service),
117        ("actor", &change.actor),
118        ("url", &change.url),
119        ("id", &change.id),
120    ] {
121        if let Some(value) = value.as_deref().filter(|v| !v.is_empty()) {
122            body.push_str(&format!(",\"{key}\":{}", json_string(value)));
123        }
124    }
125    body.push('}');
126    Some(body)
127}
128
129/// `"rust 1.85.0"`, or just `"rust"` if build.rs couldn't read the compiler's version.
130pub fn runtime() -> String {
131    match option_env!("FORGE_OPS_RUSTC_VERSION") {
132        Some(version) => format!("rust {version}"),
133        None => "rust".to_string(),
134    }
135}
136
137/// Whether an environment variable's name is host-specific noise (or this crate's own
138/// configuration) rather than something the app itself defines: every host in a fleet has a
139/// different HOSTNAME, and a platform adds and drops its own variables on every restart, which
140/// would otherwise show up as a change on every deploy. The same list every ForgeOps SDK uses.
141pub fn is_noise_env_var_name(name: &str) -> bool {
142    const EXACT: [&str; 19] = [
143        "HOSTNAME",
144        "HOST",
145        "HOME",
146        "PATH",
147        "PWD",
148        "OLDPWD",
149        "SHLVL",
150        "_",
151        "TERM",
152        "USER",
153        "LOGNAME",
154        "SHELL",
155        "LANG",
156        "TMPDIR",
157        "TZ",
158        "PORT",
159        "DYNO",
160        "INVOCATION_ID",
161        "JOURNAL_STREAM",
162    ];
163    const PREFIXES: [&str; 5] = [
164        "LC_",
165        "SYSTEMD_",
166        "MEMORY_PRESSURE_",
167        "KUBERNETES_",
168        "FORGE_OPS_",
169    ];
170
171    // Upper-cased first: Windows treats names case-insensitively, so "Path" is PATH there.
172    let name = name.to_ascii_uppercase();
173    EXACT.contains(&name.as_str())
174        || PREFIXES.iter().any(|prefix| name.starts_with(prefix))
175        || name.ends_with("_SERVICE_HOST")
176        || name.contains("_SERVICE_PORT")
177        || name
178            .find("_PORT_")
179            .is_some_and(|i| name[i + "_PORT_".len()..].contains("_TCP"))
180}
181
182/// The names (never the values) of `names`, minus the noise above, sorted and deduplicated so the
183/// same set always encodes the same way.
184pub fn env_var_names(names: impl IntoIterator<Item = String>) -> Vec<String> {
185    let mut kept: Vec<String> = names
186        .into_iter()
187        .filter(|name| !name.is_empty() && !is_noise_env_var_name(name))
188        .collect();
189    kept.sort();
190    kept.dedup();
191    kept
192}
193
194/// The `/change_snapshots` request body. `env_names` is only read when
195/// `Configuration.track_env_var_names` is on.
196pub fn snapshot_body(
197    configuration: &Configuration,
198    env_names: impl FnOnce() -> Vec<String>,
199) -> String {
200    let mut state = format!("\"runtime\":{}", json_string(&runtime()));
201    if configuration.track_env_var_names {
202        let names: Vec<String> = env_var_names(env_names())
203            .iter()
204            .map(|name| json_string(name))
205            .collect();
206        state.push_str(&format!(",\"env_var_names\":[{}]", names.join(",")));
207    }
208    format!(
209        "{{\"environment\":{},\"state\":{{{state}}}}}",
210        json_string(&configuration.environment)
211    )
212}
213
214/// Every environment variable name this process sees, skipping any that isn't valid UTF-8.
215/// `vars_os`, not `vars`: `vars` panics on a non-UTF-8 name or value, and the values are never
216/// looked at here.
217pub fn process_env_var_names() -> Vec<String> {
218    std::env::vars_os()
219        .filter_map(|(name, _)| name.into_string().ok())
220        .collect()
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226    use std::time::Duration;
227
228    fn config() -> Configuration {
229        let mut config = Configuration::new();
230        config.environment = "production".to_string();
231        config
232    }
233
234    #[test]
235    fn change_body_has_the_required_fields_and_defaults_environment() {
236        let body = change_body(
237            &config(),
238            &Change {
239                occurred_at: Some(UNIX_EPOCH + Duration::from_secs(1_700_000_000)),
240                ..Change::new("migration", "Add index to orders")
241            },
242        )
243        .unwrap();
244        assert_eq!(
245            body,
246            "{\"kind\":\"migration\",\"title\":\"Add index to orders\",\"environment\":\"production\",\"occurred_at\":\"2023-11-14T22:13:20Z\"}"
247        );
248    }
249
250    #[test]
251    fn change_body_includes_every_optional_field_given() {
252        let body = change_body(
253            &config(),
254            &Change {
255                details: crate::context! {"flag" => "new_checkout"},
256                environment: Some("staging".to_string()),
257                service: Some("billing".to_string()),
258                actor: Some("alice".to_string()),
259                url: Some("https://example.com/pr/1".to_string()),
260                id: Some("flag-42".to_string()),
261                ..Change::new("feature_flag", "Enabled \"new checkout\"")
262            },
263        )
264        .unwrap();
265        assert!(body.starts_with(
266            "{\"kind\":\"feature_flag\",\"title\":\"Enabled \\\"new checkout\\\"\",\"environment\":\"staging\""
267        ));
268        assert!(body.contains(",\"details\":{\"flag\":\"new_checkout\"}"));
269        assert!(body.ends_with(
270            ",\"service\":\"billing\",\"actor\":\"alice\",\"url\":\"https://example.com/pr/1\",\"id\":\"flag-42\"}"
271        ));
272    }
273
274    #[test]
275    fn an_unknown_kind_is_sent_as_other() {
276        for kind in ["deploy", "", "Feature_Flag"] {
277            let body = change_body(&config(), &Change::new(kind, "x")).unwrap();
278            assert!(body.starts_with("{\"kind\":\"other\","), "kind = {kind:?}");
279        }
280        for kind in KINDS {
281            let body = change_body(&config(), &Change::new(kind, "x")).unwrap();
282            assert!(body.starts_with(&format!("{{\"kind\":\"{kind}\",")));
283        }
284    }
285
286    #[test]
287    fn a_blank_title_is_dropped_and_a_long_one_truncated() {
288        assert_eq!(change_body(&config(), &Change::new("config", "  ")), None);
289        let body = change_body(&config(), &Change::new("config", &"é".repeat(300))).unwrap();
290        assert!(body.contains(&format!("\"title\":\"{}\"", "é".repeat(200))));
291    }
292
293    #[test]
294    fn runtime_names_rust_and_the_compiler_version_when_known() {
295        let runtime = runtime();
296        assert!(
297            runtime == "rust" || runtime.starts_with("rust 1."),
298            "{runtime}"
299        );
300    }
301
302    #[test]
303    fn the_denylist_drops_host_noise_and_keeps_app_names() {
304        for name in [
305            "HOSTNAME",
306            "PATH",
307            "Path",
308            "_",
309            "LC_ALL",
310            "SYSTEMD_EXEC_PID",
311            "MEMORY_PRESSURE_WATCH",
312            "KUBERNETES_SERVICE_HOST",
313            "REDIS_SERVICE_HOST",
314            "REDIS_SERVICE_PORT",
315            "REDIS_SERVICE_PORT_HTTP",
316            "REDIS_PORT_6379_TCP",
317            "REDIS_PORT_6379_TCP_ADDR",
318            "FORGE_OPS_DSN",
319            "DYNO",
320        ] {
321            assert!(is_noise_env_var_name(name), "{name} should be dropped");
322        }
323        for name in [
324            "DATABASE_URL",
325            "STRIPE_KEY",
326            "REDIS_PORT",
327            "PORTAL_URL",
328            "HOSTS",
329        ] {
330            assert!(!is_noise_env_var_name(name), "{name} should be kept");
331        }
332    }
333
334    #[test]
335    fn snapshot_sends_runtime_only_by_default() {
336        let body = snapshot_body(&config(), || panic!("env names read without opting in"));
337        assert_eq!(
338            body,
339            format!(
340                "{{\"environment\":\"production\",\"state\":{{\"runtime\":{}}}}}",
341                json_string(&runtime())
342            )
343        );
344        assert!(!body.contains("dependencies") && !body.contains("schema_version"));
345    }
346
347    #[test]
348    fn snapshot_sends_sorted_filtered_env_var_names_when_opted_in() {
349        let mut config = config();
350        config.track_env_var_names = true;
351        let body = snapshot_body(&config, || {
352            [
353                "STRIPE_KEY",
354                "HOME",
355                "DATABASE_URL",
356                "FORGE_OPS_DSN",
357                "STRIPE_KEY",
358            ]
359            .map(str::to_string)
360            .to_vec()
361        });
362        assert!(
363            body.ends_with(",\"env_var_names\":[\"DATABASE_URL\",\"STRIPE_KEY\"]}}"),
364            "{body}"
365        );
366    }
367
368    #[test]
369    fn process_env_var_names_are_names_only() {
370        let names = process_env_var_names();
371        assert!(names.iter().all(|name| !name.contains('=')));
372    }
373}